Initial commit
This commit is contained in:
76
skills/optimizing-query-performance/SKILL.md
Normal file
76
skills/optimizing-query-performance/SKILL.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: optimizing-query-performance
|
||||
description: Optimize queries with indexes, batching, and efficient Prisma operations for production performance.
|
||||
allowed-tools: Read, Write, Edit, Bash
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
<overview>
|
||||
Query optimization requires strategic indexing, efficient batching, and monitoring to prevent common production anti-patterns.
|
||||
|
||||
Key capabilities: Strategic index placement (@@index, @@unique) · Efficient batch operations (createMany, transactions) · Query analysis & N+1 prevention · Field selection optimization & cursor pagination
|
||||
</overview>
|
||||
|
||||
<workflow>
|
||||
**Phase 1 — Identify:** Enable query logging; analyze patterns/execution times; identify missing indexes, N+1 problems, or inefficient batching
|
||||
|
||||
**Phase 2 — Optimize:** Add indexes for filtered/sorted fields; replace loops with batch operations; select only needed fields; use cursor pagination for large datasets
|
||||
|
||||
**Phase 3 — Validate:** Measure execution time before/after; verify index usage with EXPLAIN ANALYZE; monitor connection pool under load
|
||||
</workflow>
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Index Strategy:**
|
||||
|
||||
| Scenario | Index Type | Example |
|
||||
| --------------------- | ----------------------------------- | ------------------------------ |
|
||||
| Single field filter | `@@index([field])` | `@@index([status])` |
|
||||
| Multiple field filter | `@@index([field1, field2])` | `@@index([userId, status])` |
|
||||
| Sort + filter | `@@index([filterField, sortField])` | `@@index([status, createdAt])` |
|
||||
|
||||
**Batch Operations:**
|
||||
|
||||
| Operation | Slow (Loop) | Fast (Batch) |
|
||||
| --------- | ---------------------- | -------------- |
|
||||
| Insert | `for...await create()` | `createMany()` |
|
||||
| Update | `for...await update()` | `updateMany()` |
|
||||
| Delete | `for...await delete()` | `deleteMany()` |
|
||||
|
||||
**Performance Gains:** Indexes (10-100x) · Batch ops (50-100x for 1000+ records) · Cursor pagination (constant vs O(n))
|
||||
|
||||
<constraints>
|
||||
**MUST:** Add indexes for WHERE/ORDER BY/FK fields with frequent queries; use createMany for 100+ records; cursor pagination for deep pagination; select only needed fields; monitor query duration in production
|
||||
|
||||
**SHOULD:** Test indexes with production data; chunk 100k+ batches into smaller sizes; use `@@index([field1, field2])` for multi-field filters; remove unused indexes
|
||||
|
||||
**NEVER:** Add indexes without performance measurement; offset pagination beyond page 100 on large tables; fetch all
|
||||
|
||||
fields when only needing few; loop with individual creates/updates; ignore slow query warnings
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
**Measure Performance:**
|
||||
```typescript
|
||||
const start = Date.now()
|
||||
const result = await prisma.user.findMany({ ... })
|
||||
console.log(`Query took ${Date.now() - start}ms`)
|
||||
```
|
||||
Expected: 50-90% improvement for indexed queries, 50-100x for batch operations
|
||||
|
||||
**Verify Index Usage:** Run EXPLAIN ANALYZE; confirm "Index Scan" vs "Seq Scan"
|
||||
|
||||
**Monitor Production:** Track P95/P99 latency; expect reduced slow query frequency
|
||||
|
||||
**Check Write Performance:** Writes may increase 10-30% per index if rarely-used; consider removal
|
||||
</validation>
|
||||
|
||||
## References
|
||||
|
||||
- **Index Strategy**: `references/index-strategy.md` — indexing patterns and trade-offs
|
||||
- **Batch Operations**: `references/batch-operations.md` — bulk operations and chunking
|
||||
- **Query Monitoring**: `references/query-monitoring.md` — logging setup and slow query analysis
|
||||
- **Field Selection**: `references/field-selection.md` — select vs include patterns and N+1 prevention
|
||||
- **Optimization Examples**: `references/optimization-examples.md` — real-world improvements
|
||||
- **Next.js Integration**: Next.js plugin for App Router-specific patterns
|
||||
- **Serverless**: CLIENT-serverless-config skill for connection pooling
|
||||
@@ -0,0 +1,93 @@
|
||||
# Batch Operations Guide
|
||||
|
||||
## createMany vs Loop
|
||||
|
||||
**SLOW (N database round-trips):**
|
||||
|
||||
```typescript
|
||||
for (const userData of users) {
|
||||
await prisma.user.create({ data: userData })
|
||||
}
|
||||
```
|
||||
|
||||
**FAST (1 database round-trip):**
|
||||
|
||||
```typescript
|
||||
await prisma.user.createMany({
|
||||
data: users,
|
||||
skipDuplicates: true
|
||||
})
|
||||
```
|
||||
|
||||
**Performance Gain:** 50-100x faster for 1000+ records.
|
||||
|
||||
**Limitations:**
|
||||
|
||||
- createMany does NOT return created records
|
||||
- Does NOT trigger middleware or relation cascades
|
||||
- skipDuplicates skips on unique constraint violations (no error)
|
||||
|
||||
## Batch Updates
|
||||
|
||||
**SLOW:**
|
||||
|
||||
```typescript
|
||||
for (const id of orderIds) {
|
||||
await prisma.order.update({
|
||||
where: { id },
|
||||
data: { status: 'shipped' }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**FAST:**
|
||||
|
||||
```typescript
|
||||
await prisma.order.updateMany({
|
||||
where: { id: { in: orderIds } },
|
||||
data: { status: 'shipped' }
|
||||
})
|
||||
```
|
||||
|
||||
**Note:** updateMany returns count, not records.
|
||||
|
||||
## Batch with Transactions
|
||||
|
||||
When you need returned records or relation handling:
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(
|
||||
users.map(userData =>
|
||||
prisma.user.create({ data: userData })
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Use Case:** Creating related records where you need IDs for subsequent operations.
|
||||
|
||||
**Trade-off:** Slower than createMany but supports relations and returns records.
|
||||
|
||||
## Batch Size Considerations
|
||||
|
||||
For very large datasets (100k+ records), chunk into batches:
|
||||
|
||||
```typescript
|
||||
const BATCH_SIZE = 1000
|
||||
|
||||
for (let i = 0; i < records.length; i += BATCH_SIZE) {
|
||||
const batch = records.slice(i, i + BATCH_SIZE)
|
||||
|
||||
await prisma.record.createMany({
|
||||
data: batch,
|
||||
skipDuplicates: true
|
||||
})
|
||||
|
||||
console.log(`Processed ${Math.min(i + BATCH_SIZE, records.length)}/${records.length}`)
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Progress visibility
|
||||
- Memory efficiency
|
||||
- Failure isolation (one batch fails, others succeed)
|
||||
@@ -0,0 +1,70 @@
|
||||
# Field Selection Guide
|
||||
|
||||
## Select vs Include
|
||||
|
||||
**select:** Choose specific fields (excludes all others)
|
||||
|
||||
```typescript
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: 1 },
|
||||
select: {
|
||||
id: true,
|
||||
email: true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**include:** Add relations to default fields
|
||||
|
||||
```typescript
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: 1 },
|
||||
include: {
|
||||
orders: true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Cannot use both select and include in same query.**
|
||||
|
||||
## Nested Selection
|
||||
|
||||
```typescript
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
orders: {
|
||||
select: {
|
||||
id: true,
|
||||
total: true,
|
||||
createdAt: true
|
||||
},
|
||||
where: { status: 'completed' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Only fetches recent completed orders, not all orders.
|
||||
|
||||
## Counting Relations Without Loading
|
||||
|
||||
```typescript
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
_count: {
|
||||
select: {
|
||||
orders: true,
|
||||
posts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Returns counts without loading actual relation records.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Index Strategy Guide
|
||||
|
||||
## When to Add Indexes
|
||||
|
||||
Add `@@index` for fields that are:
|
||||
|
||||
- Frequently used in where clauses
|
||||
- Used for sorting (orderBy)
|
||||
- Foreign keys with frequent joins
|
||||
- Composite conditions used together
|
||||
|
||||
## Single-Field Indexes
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
createdAt DateTime @default(now())
|
||||
status String
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([status])
|
||||
}
|
||||
```
|
||||
|
||||
**Use Case:**
|
||||
|
||||
```typescript
|
||||
await prisma.user.findMany({
|
||||
where: { status: 'active' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20
|
||||
})
|
||||
```
|
||||
|
||||
Both status filter and createdAt sort benefit from indexes.
|
||||
|
||||
## Composite Indexes
|
||||
|
||||
```prisma
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
status String
|
||||
createdAt DateTime @default(now())
|
||||
totalCents Int
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
```
|
||||
|
||||
**Composite Index Rules:**
|
||||
|
||||
1. Order matters: [userId, status] helps queries filtering by userId, or userId + status
|
||||
2. Does NOT help queries filtering only by status
|
||||
3. Most selective field should come first
|
||||
4. Match your most common query patterns
|
||||
|
||||
**Use Case:**
|
||||
|
||||
```typescript
|
||||
await prisma.order.findMany({
|
||||
where: {
|
||||
userId: 123,
|
||||
status: 'pending'
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
```
|
||||
|
||||
First index [userId, status] optimizes the where clause.
|
||||
Second index [status, createdAt] would help if querying by status alone with date sorting.
|
||||
|
||||
## Index Trade-offs
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Faster read queries (10-100x improvement on large tables)
|
||||
- Required for efficient sorting and filtering
|
||||
- Essential for foreign key performance
|
||||
|
||||
**Costs:**
|
||||
|
||||
- Slower writes (insert/update/delete must update indexes)
|
||||
- Storage overhead (5-20% per index)
|
||||
- Diminishing returns beyond 5-7 indexes per table
|
||||
|
||||
**Rule:** Only index fields actually used in queries. Remove unused indexes.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Optimization Examples
|
||||
|
||||
## Example 1: Add Composite Index for Common Query
|
||||
|
||||
**Scenario:** API endpoint filtering orders by userId and status, sorted by date
|
||||
|
||||
**Current Schema:**
|
||||
|
||||
```prisma
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
status String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
```
|
||||
|
||||
**Query:**
|
||||
|
||||
```typescript
|
||||
await prisma.order.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
status: 'pending'
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
```
|
||||
|
||||
**Optimization - Add Composite Index:**
|
||||
|
||||
```prisma
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
status String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId, status, createdAt])
|
||||
}
|
||||
```
|
||||
|
||||
Index covers filter AND sort, enabling index-only scan.
|
||||
|
||||
## Example 2: Optimize Bulk Insert
|
||||
|
||||
**Scenario:** Import 10,000 products from CSV
|
||||
|
||||
**SLOW Approach:**
|
||||
|
||||
```typescript
|
||||
for (const row of csvData) {
|
||||
await prisma.product.create({
|
||||
data: {
|
||||
name: row.name,
|
||||
sku: row.sku,
|
||||
price: parseFloat(row.price)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
10,000 database round-trips = 60+ seconds
|
||||
|
||||
**FAST Approach:**
|
||||
|
||||
```typescript
|
||||
const products = csvData.map(row => ({
|
||||
name: row.name,
|
||||
sku: row.sku,
|
||||
price: parseFloat(row.price)
|
||||
}))
|
||||
|
||||
await prisma.product.createMany({
|
||||
data: products,
|
||||
skipDuplicates: true
|
||||
})
|
||||
```
|
||||
|
||||
1 database round-trip = <1 second
|
||||
|
||||
**Even Better - Chunked Batches:**
|
||||
|
||||
```typescript
|
||||
const BATCH_SIZE = 1000
|
||||
|
||||
for (let i = 0; i < products.length; i += BATCH_SIZE) {
|
||||
const batch = products.slice(i, i + BATCH_SIZE)
|
||||
|
||||
await prisma.product.createMany({
|
||||
data: batch,
|
||||
skipDuplicates: true
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Progress tracking + failure isolation.
|
||||
|
||||
## Example 3: Identify and Fix Slow Query
|
||||
|
||||
**Enable Logging:**
|
||||
|
||||
```typescript
|
||||
const prisma = new PrismaClient({
|
||||
log: [{ emit: 'event', level: 'query' }]
|
||||
})
|
||||
|
||||
prisma.$on('query', (e) => {
|
||||
if (e.duration > 500) {
|
||||
console.log(`SLOW QUERY (${e.duration}ms): ${e.query}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Detected Slow Query:**
|
||||
|
||||
```
|
||||
SLOW QUERY (3421ms): SELECT * FROM "Post" WHERE "published" = true ORDER BY "views" DESC LIMIT 10
|
||||
```
|
||||
|
||||
**Analyze with EXPLAIN:**
|
||||
|
||||
```typescript
|
||||
await prisma.$queryRaw`
|
||||
EXPLAIN ANALYZE
|
||||
SELECT * FROM "Post"
|
||||
WHERE "published" = true
|
||||
ORDER BY "views" DESC
|
||||
LIMIT 10
|
||||
`
|
||||
```
|
||||
|
||||
**Output shows:** Seq Scan (full table scan)
|
||||
|
||||
**Solution - Add Index:**
|
||||
|
||||
```prisma
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
published Boolean @default(false)
|
||||
views Int @default(0)
|
||||
|
||||
@@index([published, views])
|
||||
}
|
||||
```
|
||||
|
||||
**Verify Improvement:**
|
||||
|
||||
After migration, same query executes in ~15ms (228x faster).
|
||||
@@ -0,0 +1,131 @@
|
||||
# Query Monitoring Guide
|
||||
|
||||
## Enable Query Logging
|
||||
|
||||
**Development:**
|
||||
|
||||
```typescript
|
||||
const prisma = new PrismaClient({
|
||||
log: [
|
||||
{ emit: 'event', level: 'query' },
|
||||
{ emit: 'stdout', level: 'error' },
|
||||
{ emit: 'stdout', level: 'warn' }
|
||||
]
|
||||
})
|
||||
|
||||
prisma.$on('query', (e) => {
|
||||
console.log('Query: ' + e.query)
|
||||
console.log('Duration: ' + e.duration + 'ms')
|
||||
})
|
||||
```
|
||||
|
||||
**Production (structured logging):**
|
||||
|
||||
```typescript
|
||||
const prisma = new PrismaClient({
|
||||
log: [{ emit: 'event', level: 'query' }]
|
||||
})
|
||||
|
||||
prisma.$on('query', (e) => {
|
||||
if (e.duration > 1000) {
|
||||
logger.warn('Slow query detected', {
|
||||
query: e.query,
|
||||
duration: e.duration,
|
||||
params: e.params
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Analyzing Slow Queries
|
||||
|
||||
**Identify Patterns:**
|
||||
|
||||
1. Queries without WHERE clause on large tables (full table scans)
|
||||
2. Complex JOINs without indexes on foreign keys
|
||||
3. ORDER BY on unindexed fields
|
||||
4. Missing LIMIT on large result sets
|
||||
|
||||
**Use Database EXPLAIN:**
|
||||
|
||||
```typescript
|
||||
await prisma.$queryRaw`EXPLAIN ANALYZE
|
||||
SELECT * FROM "User"
|
||||
WHERE status = 'active'
|
||||
ORDER BY "createdAt" DESC
|
||||
LIMIT 20
|
||||
`
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- "Seq Scan" (sequential scan) - needs index
|
||||
- "Index Scan" - good
|
||||
- High execution time relative to query complexity
|
||||
|
||||
## Common Query Anti-Patterns
|
||||
|
||||
**N+1 Problem:**
|
||||
|
||||
```typescript
|
||||
const users = await prisma.user.findMany()
|
||||
|
||||
for (const user of users) {
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId: user.id }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Solution - Use include:**
|
||||
|
||||
```typescript
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
orders: true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Over-fetching:**
|
||||
|
||||
```typescript
|
||||
const users = await prisma.user.findMany()
|
||||
```
|
||||
|
||||
Fetches ALL fields for ALL users.
|
||||
|
||||
**Solution - Select needed fields:**
|
||||
|
||||
```typescript
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Offset Pagination on Large Datasets:**
|
||||
|
||||
```typescript
|
||||
await prisma.user.findMany({
|
||||
skip: 50000,
|
||||
take: 20
|
||||
})
|
||||
```
|
||||
|
||||
Database must scan and skip 50,000 rows.
|
||||
|
||||
**Solution - Cursor pagination:**
|
||||
|
||||
```typescript
|
||||
await prisma.user.findMany({
|
||||
take: 20,
|
||||
cursor: { id: lastSeenId },
|
||||
skip: 1
|
||||
})
|
||||
```
|
||||
|
||||
Constant time regardless of page depth.
|
||||
Reference in New Issue
Block a user