-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix(database/gdb): Fix N+1 query problem and add batch query control #4672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
- 新增Preload方法实现批量关联查询 - 添加软删除支持包括deleted_at字段 - 实现分块查询优化大数据量性能 - 添加preloadOptions配置支持分块大小设置 - 扩展ORM标签支持chunkName、chunkSize、chunkMinRows属性 - 新增四层嵌套预加载测试验证数据正确性 - 优化关联查询性能对比传统N+1模式
|
@LanceAdd 这个PR超棒👍👍!!但有几点改进建议,欢迎一起讨论:
我再说一次,这个PR超棒👍👍!!期待一起讨论改进建议,继续完善这个功能!我会持续关注该PR。 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces a new Preload mode for gdb.Model association loading to address the N+1 query problem by switching from per-parent queries to batched WHERE IN (...) queries, with optional chunking controls via struct tags and runtime options.
Changes:
- Add
Model.Preload()to enable batch recursive association loading andModel.PreloadOptions()to override chunking behavior at runtime. - Extend
orm:"with:..."tag parsing to supportchunkName,chunkSize, andchunkMinRows. - Add a multi-layer MySQL test + supporting SQL templates for validating preload correctness and soft-delete/unscoped behavior.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| database/gdb/gdb_model_with.go | Adds Preload/PreloadOptions, integrates preload path into With scanning, and parses chunk tag options. |
| database/gdb/gdb_model_preload.go | Implements the new batch preload engine (relation discovery, batch queries, result mapping, recursion). |
| database/gdb/gdb_model_struct_cache.go | Adds a global struct metadata cache to reduce repeated reflection cost for preload/with. |
| database/gdb/gdb_model.go | Introduces preload, preloadOptions, and PreloadOption configuration on Model. |
| database/gdb/gdb_func.go | Adds ORM tag constants for chunk controls (chunkName/chunkSize/chunkMinRows). |
| contrib/drivers/mysql/testdata/with_tpl_user_soft_delete.sql | New soft-delete-capable user table template for tests. |
| contrib/drivers/mysql/testdata/with_tpl_user_scores_soft_delete.sql | New soft-delete-capable scores table template for tests. |
| contrib/drivers/mysql/testdata/with_tpl_user_detail.sql | Adds deleted_at to user detail schema for unscoped tests. |
| contrib/drivers/mysql/testdata/with_tpl_score_details_soft_delete.sql | New soft-delete-capable score details template for tests. |
| contrib/drivers/mysql/testdata/with_tpl_detail_meta_soft_delete.sql | New soft-delete-capable detail meta template for tests. |
| contrib/drivers/mysql/mysql_z_unit_preload_four_layers_comparison_test.go | New 4-layer comparison test for legacy vs preload vs chunk behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| m.preload = true | ||
| if len(enable) > 0 { | ||
| model.preload = enable[0] | ||
| } | ||
| return m |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preload is mutating the receiver (m.preload = true) instead of the model returned by getModel(). In safe mode this means the returned cloned model won’t have preload enabled, and state can also leak across chains. Set model.preload (defaulting to true when no args) rather than m.preload.
| m.preload = true | |
| if len(enable) > 0 { | |
| model.preload = enable[0] | |
| } | |
| return m | |
| if len(enable) > 0 { | |
| model.preload = enable[0] | |
| } else { | |
| model.preload = true | |
| } | |
| return model |
| m.preload = true | ||
| if len(enable) > 0 { | ||
| model.preload = enable[0] | ||
| } | ||
| return m |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preload currently returns m, not the (possibly cloned) model from getModel(). This breaks chaining semantics when Safe() is enabled (and is inconsistent with methods like WithAll). Return model so the caller receives the updated configuration.
| m.preload = true | |
| if len(enable) > 0 { | |
| model.preload = enable[0] | |
| } | |
| return m | |
| model.preload = true | |
| if len(enable) > 0 { | |
| model.preload = enable[0] | |
| } | |
| return model |
| // If preload mode is enabled, use the new batch preload logic | ||
| if m.preload { | ||
| return m.doPreloadScan(pointer) | ||
| } |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preload fast-path runs before unwrapping pointer when it is a reflect.Value. ScanList recursion passes reflect.Value (e.g. bindToAttrValue.Addr()), so doPreloadScan will see a non-pointer and fail. Move the reflect.Value -> interface conversion above this m.preload check (or make doPreloadScan accept reflect.Value).
| // Append all elements from this child slice to the merged slice | ||
| // Since the elements are pointers, modifications to them will be reflected in the original slice | ||
| for j := 0; j < fieldValue.Len(); j++ { | ||
| mergedSlice = reflect.Append(mergedSlice, fieldValue.Index(j)) | ||
| } |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This merge step appends fieldValue.Index(j) into mergedSlice and assumes elements are pointers (“modifications … will be reflected”). If the relation field is []T (non-pointer elements), this copies values and deeper preloads won’t update the originals. Consider building a []*T slice via Addr() for struct elements (when addressable) to preserve in-place updates.
| // For single type relations, collect all non-nil values into a temporary slice | ||
| // Get the element type of the pointer field | ||
| fieldType := relation.Field.Type().Type | ||
| if fieldType.Kind() != reflect.Pointer { | ||
| return nil // Not a pointer field, skip |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For non-slice relations, recursion is skipped unless the relation field is a pointer (fieldType.Kind() != reflect.Pointer). This means nested relations under a struct-typed association field (e.g. UserDetail UserDetail \orm:"with:..."`) will never be preloaded. Add support for struct (and possibly pointer/struct) relation fields by collecting addressable values and calling recursivePreload` on them.
| // IMPORTANT: Clone the model for each chunk to avoid accumulating WHERE conditions | ||
| chunkModel := model.Clone() | ||
| chunkRecords, err := chunkModel.Where(relation.sourceField, chunkValues).All() | ||
| if err != nil && !errors.Is(err, sql.ErrNoRows) { |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
queryRelation fetches related rows with All() without restricting columns. Legacy With uses Fields(fieldKeys) to limit selected columns to the related struct’s fields; doing that here would reduce IO/memory and better align with the performance goal of preload.
| // Enable debug to see SQL queries | ||
| oldDebug := db.GetDebug() | ||
| db.SetDebug(true) | ||
|
|
||
| startTime := time.Now() |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test enables db.SetDebug(true) on a large dataset and relies on console output to count queries. In CI this can produce extremely large logs (especially in legacy/N+1 mode) and slow/flakes the suite. Prefer keeping debug off and counting SELECTs via a HookHandler.Select counter (and assert expected counts/reductions) instead of relying on console output.
FIX: #4620
问题背景
什么是 N+1 查询问题?
在处理关联数据时,传统的 ORM 查询方式会导致严重的性能问题:
场景示例:
传统查询方式(Legacy Mode):
这就是经典的 N+1 查询问题,会导致:
N+1 查询问题详解
实际案例分析
数据结构:
数据规模:
Legacy 模式查询次数:
性能影响:
解决方案:Preload 预加载
核心思想
通过 添加TAG
chunkName、chunkSize、chunkMinRows控制批次大小,作为兜底分批查询策略。通过
PreloadOptions(options ...gdb.PreloadOption)动态覆盖精准控制, 作为动态分批查询策略核心功能特性
1. Preload() 方法
启用预加载模式,解决 N+1 问题。
2. 智能分批(Chunk)
通过
chunkName、chunkSize、chunkMinRows控制批次大小。ORM 标签配置:
API 运行时覆盖:
3. 支持复杂查询条件
每层关联都支持:
where- 过滤条件order- 排序unscoped- 包含软删除记录4. 多层嵌套支持
支持任意深度的关联嵌套:
使用指南
基础用法
1. 定义结构体
2. 使用 Preload 查询
高级用法
1. 添加查询条件
2. 配置分批参数
参数说明:
chunkName: 分批组名称,用于 API 覆盖chunkSize: 每批查询的记录数chunkMinRows: 触发分批的最小行数3. 运行时覆盖分批参数
4. 包含软删除记录
5. 多层嵌套关联