Resolve N+1 Query Problem
Introduction
N+1 Query Problemis a performance issue that occurs when an application executes1initial query to fetch a list ofNrecords and then executes N additional sub-queries to fetch related data for each record.- The total number of queries sent to the
Databasewill beN + 1. AsNincreases (for example N = 1000), the application must execute1001SQLstatements, causing anI/O bottleneck, increasing latency and overloading theDatabase.
Example
Suppose you need to display 10 products (N = 10) along with the list of variants for each product:
- First Query (1): Get the list of 10 products:
SELECT * FROM products LIMIT 10 - Next N Queries (N = 10): Iterate through each product to get variants.
SELECT * FROM product_variants WHERE product_id = 1;
SELECT * FROM product_variants WHERE product_id = 2;
...
SELECT * FROM product_variants WHERE product_id = 10;
- Total:
10+1=11SQL queries. If fetching1,000products, the number of queries spikes to1,001SQL queries.
Solutions
Depending on the application architecture (standard REST API or GraphQL, ORM you use), here are the approaches to handle it:
Eager Loading with JOIN
- Designed for traditional
REST API / SQL / ORM - Instead of performing individual queries inside a loop, we instruct the
ORMto perform aJOINoperation within a single query. - Mechanism: Combine data from the main table and child table at the
Databaselevel.
Grouping by Array of IDs
If you do not want to use JOIN (due to duplicate main table data in the returned results), you can gather all IDs together and run exactly 2 queries
- Query 1: Get the list of products.
- Query 2: Get all variants belonging to the IDs obtained in Query 1 (used with
WHERE IN or EXISTS)
Using DataLoader
- This is the standard solution for
GraphQL - In
GraphQL, because clients have the flexibility to query fields on demand, independent resolvers can easily causeN+1.DataLoaderhelps resolve this issue usingBatchingandCachingtechniques within the scope of a single request. - Operational Mechanism:
- When child
resolverscallloader.load(productId),DataLoaderdoes not send the query immediately but waits (ticks theEvent Loop). - It collects all
productIdswithin the same render cycle into an array. - Sends a single query containing
WHERE id IN (...)to theDatabase. - Distributes the results back correctly to each resolver.
- When child
AST-Based Query Resolution
- This is a feature of modern
GraphQL Engines,frameworksorGraphQL plugins(such as@pothos/plugin-drizzle,Hasura,Prisma-GraphQL) that do not wait for child resolvers to execute. They analyze theAST (Abstract Syntax Tree)syntax of theGraphQL Querysent by the client. - Mechanism: Look ahead at all fields requested by the client (for example client requests
products + variants) to automatically generate a singleSQL JOINorJSON_AGGquery in theDatabasebefore data is returned to the app.
Detail
In the previous article I guided you on using Pothos to implement GraphQL, if you retrieve product data along with variants
When checking logs you will see the N+1 Query Problem appear as follows, for each product there is 1 query to fetch its corresponding variant
[SQL Query] Query: select "id", "created_at", "updated_at", "name", "description" from "products" order by "products"."id" asc limit $1 -- params: [6]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [1]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [2]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [3]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [4]
[SQL Query] Query: select "id", "created_at", "updated_at", "product_id", "sku", "variation_name", "price", "stock", "views" from "product_variants" where "product_variants"."product_id" = $1 -- params: [5]
Next I will guide you to resolve the N+1 Query problem as follows
Create file src/drizzle-orm/schema/schema.ts
import {relations, sql} from 'drizzle-orm'
import {
check,
decimal,
integer,
pgTable,
serial,
text,
timestamp,
unique,
varchar,
} from 'drizzle-orm/pg-core'
export const baseColumns = {
id: serial('id').primaryKey(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}
export const users = pgTable('users', {
...baseColumns,
username: varchar('username', {length: 255}).notNull().unique(),
email: varchar('email', {length: 255}).notNull().unique(),
password: text('password').notNull(),
firstName: varchar('first_name', {length: 100}).notNull(),
lastName: varchar('last_name', {length: 100}).notNull(),
phone: varchar('phone', {length: 20}),
})
export const products = pgTable('products', {
...baseColumns,
name: varchar('name', {length: 255}).notNull(),
description: text('description'),
})
export const productVariants = pgTable(
'product_variants',
{
...baseColumns,
productId: integer('product_id')
.notNull()
.references(() => products.id, {onDelete: 'cascade'}),
sku: varchar('sku', {length: 100}).notNull().unique(),
variationName: varchar('variation_name', {length: 100}).notNull(),
price: decimal('price', {precision: 12, scale: 2}).notNull(),
stock: integer('stock').notNull().default(0),
},
table => [check('stock_gte_zero', sql`${table.stock} >= 0`)]
)
export const carts = pgTable('carts', {
...baseColumns,
userId: integer('user_id')
.notNull()
.unique()
.references(() => users.id, {onDelete: 'cascade'}),
})
export const cartItems = pgTable(
'cart_items',
{
...baseColumns,
cartId: integer('cart_id')
.notNull()
.references(() => carts.id, {onDelete: 'cascade'}),
variantId: integer('variant_id')
.notNull()
.references(() => productVariants.id),
quantity: integer('quantity').default(0),
},
table => [
unique('cart_items_cart_id_variant_id_unique').on(
table.cartId,
table.variantId
),
]
)
export const productsRelations = relations(products, ({many}) => ({
variants: many(productVariants),
}))
export const productVariantsRelations = relations(
productVariants,
({one, many}) => ({
product: one(products, {
fields: [productVariants.productId],
references: [products.id],
}),
cartItems: many(cartItems),
})
)
export const cartsRelations = relations(carts, ({many}) => ({
items: many(cartItems),
}))
export const cartItemsRelations = relations(cartItems, ({one}) => ({
cart: one(carts, {
fields: [cartItems.cartId],
references: [carts.id],
}),
variant: one(productVariants, {
fields: [cartItems.variantId],
references: [productVariants.id],
}),
}))
Please note that you must create relations like productsRelations, productVariantsRelations, cartsRelations, cartItemsRelations, which are key factors to implementing AST-Based Query Resolution
Create file src/drizzle-orm/index.ts
import {DefaultLogger, type LogWriter} from 'drizzle-orm'
import {drizzle, type NodePgDatabase} from 'drizzle-orm/node-postgres'
import {Pool} from 'pg'
import {DRIZZLE_DB} from 'src/constant'
import * as schema from './schema/schema'
class CustomLogger implements LogWriter {
write(query: string): void {
const timestamp = new Date().toISOString()
console.log(`\x1b[36m[SQL Query]\x1b[0m ${query}`)
}
}
export type DrizzleDB = NodePgDatabase<typeof schema>
export const createPool = () => {
return new Pool({connectionString: process.env.DATABASE_URL})
}
export const createDrizzle = () => {
const pool = createPool()
return drizzle(pool, {
schema,
logger: new DefaultLogger({writer: new CustomLogger()}),
})
}
export const drizzleProvider = {
provide: DRIZZLE_DB,
useFactory: createDrizzle,
}
With CustomLogger you can customize the log output content, these are the SQL queries generated by Drizzle ORM, enabling this log helps us verify the N+1 Query problem
Create file src/pothos-graphql/builder.ts
import SchemaBuilder from '@pothos/core'
import DrizzlePlugin from '@pothos/plugin-drizzle'
import type {ExtractTablesWithRelations} from 'drizzle-orm'
import type {DrizzleDB} from 'src/drizzle-orm'
import * as schema from 'src/drizzle-orm/schema/schema'
export interface GraphQLContext {
db: DrizzleDB
}
type Schema = typeof schema
type DrizzleTableRelations = ExtractTablesWithRelations<Schema>
export const builder = new SchemaBuilder<{
Context: GraphQLContext
DrizzleSchema: Schema
DrizzleRelations: DrizzleTableRelations
}>({
plugins: [DrizzlePlugin],
drizzle: {
client: ((ctx: GraphQLContext) => ctx.db) as never,
schema,
},
})
builder.queryType({})
Create file src/pothos-graphql/schema/product.schema.ts
import {
resolveCursorConnection,
type ResolveCursorConnectionArgs,
} from '@pothos/plugin-relay'
import {and, asc, desc, eq, gt, lt} from 'drizzle-orm'
import {products} from 'src/drizzle-orm/schema/schema'
import {builder} from '../builder'
export const ProductVariantType = builder.drizzleObject('productVariants', {
name: 'ProductVariant',
fields: t => ({
id: t.exposeInt('id'),
sku: t.exposeString('sku'),
variationName: t.exposeString('variationName'),
price: t.float({
resolve: variant => Number(variant.price),
}),
stock: t.exposeInt('stock'),
}),
})
const ProductType = builder.drizzleObject('products', {
name: 'Product',
fields: t => ({
id: t.exposeInt('id'),
name: t.exposeString('name'),
description: t.exposeString('description', {nullable: true}),
variants: t.relation('variants'),
}),
})
builder.queryFields(t => ({
products: t.connection({
type: ProductType,
resolve: (_root, args, ctx) => {
return resolveCursorConnection(
{
args,
toCursor: product => String(product.id),
},
({before, after, limit, inverted}: ResolveCursorConnectionArgs) => {
const filters = [
before ? lt(products.id, Number(before)) : undefined,
after ? gt(products.id, Number(after)) : undefined,
].filter(Boolean)
return ctx.db
.select()
.from(products)
.where(filters.length > 0 ? and(...filters) : undefined)
.orderBy(inverted ? desc(products.id) : asc(products.id))
.limit(limit)
}
)
},
}),
product: t.drizzleField({
type: ProductType,
nullable: true,
args: {
id: t.arg.int({required: true}),
},
resolve: (query, _root, args, ctx) => {
return ctx.db.query.products.findFirst(
query({
where: eq(products.id, args.id),
})
)
},
}),
}))
- Query product list uses
resolveCursorConnectionto implement cursor pagination builder.drizzleObjectacts as a type definition bridge betweenDrizzle SchemaandGraphQL Schema.- The
ProductTypesection usesvariants: t.relation('variants'), which is pulled fromproductsRelationsdefined inschema.tsabove and it will utilizeAST-Based Query Resolution - Normally in
GraphQL, each field resolver executes completely independently and does not know what data other fields require. HoweverPothosandDrizzleresolveN+1by:- Reading
GraphQL AST (GraphQLResolveInfo): When a request arrives, the plugin inspects theGraphQL ASTsyntax tree from the root resolver's info argument to know which fields and relationships the client is querying (for example: which products the client requests to select subsequent variants). - Matching with
Drizzle Schema Relations: Based on relation declarations in theDrizzle schema, the plugin automatically maps selected fields from theGraphQL ASTto relationships in the database. - Building a single optimized Query: Instead of running
db.select().from(products)and looping through sub-queries, the plugin translates the entire request intoDrizzle Relational Querysyntax (db.query.products.findMany({ with: { variants: true } })). - Executing on the
Database:Drizzle ORMunderneath converts that command into a singleSQLstatement (typically usingLEFT JOINor aggregated queries withjson_build_object / json_aggdepending on the dialect) to fetch exact data required by the client in a single round-trip.
- Reading
Create file src/pothos-graphql/schema/cart.schema.ts
import {eq} from 'drizzle-orm'
import {carts} from 'src/drizzle-orm/schema/schema'
import {builder} from '../builder'
builder.drizzleObject('cartItems', {
name: 'CartItem',
fields: t => ({
id: t.exposeInt('id'),
quantity: t.exposeInt('quantity'),
variant: t.relation('variant'),
}),
})
const CartType = builder.drizzleObject('carts', {
name: 'Cart',
fields: t => ({
id: t.exposeInt('id'),
userId: t.exposeInt('userId'),
items: t.relation('items'),
totalQuantity: t.field({
type: 'Int',
select: {
columns: {},
with: {
items: {
columns: {
quantity: true,
},
},
},
},
resolve: cart =>
cart.items.reduce((sum, item) => sum + (item.quantity ?? 0), 0),
}),
totalAmount: t.field({
type: 'Float',
select: {
columns: {},
with: {
items: {
columns: {
quantity: true,
},
with: {
variant: {
columns: {
price: true,
},
},
},
},
},
},
resolve: cart =>
cart.items.reduce(
(sum, item) =>
sum + (item.quantity ?? 0) * Number(item.variant.price),
0
),
}),
}),
})
builder.queryFields(t => ({
cart: t.drizzleField({
type: CartType,
nullable: true,
args: {
id: t.arg.int({required: true}),
},
resolve: (query, _root, args, ctx) =>
ctx.db.query.carts.findFirst(
query({
where: eq(carts.id, args.id),
})
),
}),
}))
cartItemsalso usesvariant: t.relation('variant')to reference the product variantitems: t.relation('items')references cart items- It will leverage
AST Analysisto merge the whole hierarchicalGraphQLquery into a singleDrizzlequery, solving theN+1issue without writing aDataLoader
- With
totalQuantityandtotalAmountas computed fields added to the cart detail response data, this is aQuery Optimization (SQL Select Binding/Relation Select)mechanism helping to resolve N+1 query problems right from the database query level.- Instead of calling DB multiple times like
DataLoader,Pothosrelies on the GraphQL Request from the client to automatically merge query configuration into query parameters inside theDrizzleFieldresolvefunction. - The task of select is to inform
Pothosthat if the client requests this field (liketotalAmount), Drizzle needs to fetch (SELECT/JOIN) additional related data from the database so theresolvefunction has enough data for computation - The syntax is identical to using
findFirst / findManyin Drizzle relational queries.
- Instead of calling DB multiple times like
select: {
columns: {},
with: {
items: {
columns: {
quantity: true,
},
},
},
}
- Meaning: When the
GraphQLclient requests thetotalQuantityfield, Pothos will useDrizzleto retrievecartinfo while simultaneously performing aJOINtocartItemsand only retrieving thequantityfield - The
resolvefunction: Receives thecart.itemsresult containing an array of objects { quantity: number } and runs areducefunction to sum them up.
select: {
columns: {},
with: {
items: {
columns: {
quantity: true,
},
with: {
variant: {
columns: {
price: true,
},
},
},
},
},
}
- Meaning: To calculate the total amount, besides the
quantityof eachcartItem, you need the additionalpricefield residing in theproduct_variantstable. This configuration instructsDrizzleto automatically perform aNested Includecarts->cartItems->variant - The
resolvefunction: Receives nested datacart.items[i].variant.priceto multiply withquantity
Create file src/pothos-graphql/schema/index.ts
import {builder} from '../builder'
import './cart.schema'
import './product.schema'
export const executableSchema = builder.toSchema()
Afterward you still need the graphql.controller.ts file using Yoga GraphQL and app.module.ts to import controllers as I mentioned in the previous article, you can check back to use it
Result when fetching product detail is as follows
Check logs to view the generated SQL query
[SQL Query] Query: select "products"."id", "products"."created_at", "products"."updated_at", "products"."name", "products"."description", "products_variants"."data" as "variants" from "products" "products" left join lateral (select coalesce(json_agg(json_build_array("products_variants"."id", "products_variants"."created_at", "products_variants"."updated_at", "products_variants"."product_id", "products_variants"."sku", "products_variants"."variation_name", "products_variants"."price", "products_variants"."stock")), '[]'::json) as "data" from "product_variants" "products_variants" where "products_variants"."product_id" = "products"."id") "products_variants" on true where "products"."id" = $1 limit $2 -- params: [10, 1]
You can see that only 2 queries were generated instead of calling a separate query for each variant
[SQL Query] Query: select "id", "created_at", "updated_at", "name", "description" from "products" order by "products"."id" asc limit $1 -- params: [6]
[SQL Query] Query: select "products"."description", "products"."id", "products"."name", "products_variants"."data" as "variants" from "products" "products" left join lateral (select coalesce(json_agg(json_build_array("products_variants"."id", "products_variants"."created_at", "products_variants"."updated_at", "products_variants"."product_id", "products_variants"."sku", "products_variants"."variation_name", "products_variants"."price", "products_variants"."stock")), '[]'::json) as "data" from "product_variants" "products_variants" where "products_variants"."product_id" = "products"."id") "products_variants" on true where "products"."id" in ($1, $2, $3, $4, $5) -- params: [1, 2, 3, 4, 5]
You can see that only 1 SQL statement was created joining everything required
[SQL Query] Query: select "carts"."id", "carts"."created_at", "carts"."updated_at", "carts"."user_id", "carts_items"."data" as "items" from "carts" "carts" left join lateral (select coalesce(json_agg(json_build_array("carts_items"."id", "carts_items"."created_at", "carts_items"."updated_at", "carts_items"."cart_id", "carts_items"."variant_id", "carts_items"."quantity", "carts_items_variant"."data")), '[]'::json) as "data" from "cart_items" "carts_items" left join lateral (select json_build_array("carts_items_variant"."id", "carts_items_variant"."created_at", "carts_items_variant"."updated_at", "carts_items_variant"."product_id", "carts_items_variant"."sku", "carts_items_variant"."variation_name", "carts_items_variant"."price", "carts_items_variant"."stock") as "data" from (select * from "product_variants" "carts_items_variant" where "carts_items_variant"."id" = "carts_items"."variant_id" limit $1) "carts_items_variant") "carts_items_variant" on true where "carts_items"."cart_id" = "carts"."id") "carts_items" on true where "carts"."id" = $2 limit $3 -- params: [1, 2011, 1]
Happy coding!
Comments
Post a Comment