
1. 多条件查询与增删改的工程化拆解1.1 为什么“多条件查询”是业务代码的分水岭刚入行的开发者写查询往往停留在“根据ID取一条记录”的阶段。一旦业务方甩过来一个筛选面板——状态是“已上架”标题里包含“春季”价格区间在某个范围还要按创建时间倒序——很多人就开始在代码里堆if了。我见过最夸张的一个接口光拼查询条件就写了三百多行后面接手的人根本不敢动。多条件查询之所以是分水岭是因为它同时考验三件事对ORM查询构造器的理解深度、对参数边界情况的处理能力、对SQL执行效率的敏感度。这三样缺一个代码要么写不出来要么写出来跑得慢要么上线后出各种诡异的数据错乱。这篇内容围绕一个典型的MVC分层示例展开把多条件查询里的LIKE模糊匹配、Map条件构造、Not取反、Order排序这几个高频操作讲透同时把增、改、删的完整链路串起来。适合已经能写单表CRUD、但一遇到复杂筛选就发怵的开发者也适合想梳理自己代码分层习惯的老手对照检查。1.2 技术选型的取舍逻辑标题里提到了两个生态的写法一个是Go语言里常见的gormgin组合另一个是PHP生态里laravel那套查询构造器。把这两者放在一起看其实很有意思——它们解决的是同一类问题但设计哲学完全不同。gorm是典型的链式调用风格db.Where(...).Where(...).Order(...).Find(...)每一步返回一个新的*gorm.DB对象条件层层叠加。它的优势是类型安全Go的编译器能帮你挡掉一部分拼写错误劣势是动态条件拼接时代码容易变得冗长尤其是条件数量不固定的时候。laravel的查询构造器则是另一种思路它更偏向“流式接口 闭包分组”-where()-orWhere()-whereIn()一路点下去配合when()方法可以优雅地处理“条件存在才拼接”的场景。PHP的弱类型在这里反而成了优势写起来更灵活但代价是运行时才暴露错误。我个人的选择习惯是如果项目对类型安全要求高、团队以静态语言为主走gorm那条路如果追求开发速度、业务逻辑变动频繁laravel那套写法更顺手。但不管选哪个核心的查询构造思路是相通的理解了其中一个另一个上手就是半天的事。1.3 MVC分层在这个场景下的具体职责很多人写MVC写着写着就把逻辑全塞进Controller了。查询条件在Controller里拼业务规则在Controller里判断数据库操作也在Controller里调。短期看没问题一旦要加个定时任务复用同样的查询逻辑就傻眼了。在这个示例里我建议的分层是这样的Controller层只负责接收HTTP请求参数、做基础的类型转换和参数校验、调用Service层、组装返回结构。它不应该知道SQL长什么样。Service层承载业务规则。比如“普通用户只能查自己创建的数据管理员可以查全部”这种判断放在这里。它调用Repository层拿数据。Repository层专门和数据库打交道。查询构造、条件拼接、排序规则都在这一层完成。换数据库或者换ORM只改这一层。Model层定义数据结构、表名映射、字段约束。保持干净不要塞业务方法。这样分的好处是当业务方说“这个筛选条件要改一下”你只需要动Repository层的一个方法Controller和Service完全不用碰。测试也好写Repository层可以单独做集成测试。2. 多条件查询的核心细节与实操要点2.1 LIKE模糊查询的三种匹配模式与性能陷阱LIKE查询看起来简单但用不好就是性能杀手。先明确三种匹配模式的区别模式写法能否走索引适用场景前缀匹配LIKE 关键词%可以走索引搜索框自动补全、编号前缀筛选后缀匹配LIKE %关键词不走索引极少使用除非数据量极小全模糊匹配LIKE %关键词%不走索引通用搜索数据量必须可控前缀匹配能走索引是因为B树索引本身是有序的。就像字典按拼音排序你要找“zhang”开头的字翻到z开头的区域就行。但你要找“包含ang的字”就只能一页一页翻。全模糊匹配就是这个道理数据库只能全表扫描。实操中我的建议是如果业务允许尽量引导用户使用前缀匹配。比如搜索订单号用户输入“ORD2024”那就用前缀匹配。如果必须全模糊一定要配合其他条件缩小范围。比如先按时间范围过滤再在结果集里做LIKE。数据量超过十万行且必须全模糊的考虑引入专门的全文检索方案不要硬扛。在gorm里写LIKE要注意参数拼接方式// 推荐使用参数占位符gorm会自动处理转义 keyword : % searchTerm % db.Where(title LIKE ?, keyword) // 不推荐手动拼接字符串有注入风险 db.Where(title LIKE % searchTerm %)在laravel里则是// 推荐 $query-where(title, like, % . $searchTerm . %); // 多个字段OR模糊 $query-where(function ($q) use ($searchTerm) { $q-where(title, like, %{$searchTerm}%) -orWhere(description, like, %{$searchTerm}%); });注意LIKE的转义字符问题经常被忽略。如果用户输入的关键词本身包含%或_不处理的话会变成通配符导致查询结果不符合预期。gorm和laravel都提供了转义方法记得用上。2.2 Map条件构造动态查询的利器当查询条件数量不固定时用Map来构造条件是最高效的方式。比如前端传过来一个筛选对象有的字段有值有的没有你不可能为每种组合写一个方法。gorm里的Map条件写法conditions : map[string]interface{}{ status: 1, is_delete: 0, } if title ! { conditions[title LIKE ?] % title % } if minPrice 0 { conditions[price ?] minPrice } db.Where(conditions).Find(results)laravel里更直接因为它的where方法本身就接受数组$conditions [status 1, is_delete 0]; if (!empty($title)) { $conditions[] [title, like, %{$title}%]; } $query-where($conditions);Map构造的核心价值在于“条件与数据分离”。你可以先把所有可能的条件收集到一个Map里最后一次性传给查询构造器。这样做的好处是条件的增删改只影响Map的填充逻辑查询语句本身保持稳定。但Map方式也有坑。第一Map的遍历顺序是不确定的虽然大多数情况下不影响查询结果但如果你的条件之间有依赖关系比如某个条件依赖于前一个条件的结果就不能用Map。第二Map方式不利于复用如果多个接口需要相同的条件组合最好抽成一个独立的条件构造方法。我的经验是简单等值条件用Map复杂条件涉及OR、分组、子查询用链式调用。两者可以混用gorm和laravel都支持在链式调用中间插入Map条件。2.3 Not取反条件的正确使用姿势Not条件在业务里很常见“排除已删除的”、“状态不是草稿的”、“ID不在黑名单里的”。但很多人写Not会写出性能问题。gorm里的Not写法// 排除单个值 db.Not(status ?, 3) // 排除多个值 db.Not(id IN ?, []int{1, 2, 3}) // 排除NULL注意SQL里NULL的比较很特殊 db.Where(deleted_at IS NOT NULL)laravel里的Not写法$query-where(status, !, 3); $query-whereNotIn(id, [1, 2, 3]); $query-whereNotNull(deleted_at);这里有一个非常容易踩的坑!和NOT IN遇到NULL值时的行为。在SQL里NULL ! 3的结果不是true而是NULL也就是未知。这意味着如果status字段允许为NULL用!过滤会把NULL值的行也排除掉但你可能本意是想保留它们的。正确的做法是明确处理NULL-- 想保留NULL的情况 WHERE (status ! 3 OR status IS NULL) -- 想排除NULL的情况 WHERE status ! 3 AND status IS NOT NULL提示在设计表结构时尽量给字段设置默认值并加上NOT NULL约束。这样能避免大量NULL相关的边界问题查询写起来也简单得多。2.4 Order排序的稳定性与索引配合排序看起来是最简单的ORDER BY created_at DESC谁都会写。但排序的坑在于稳定性和性能。先说稳定性。如果created_at字段有重复值同一秒创建了多条记录那么ORDER BY created_at DESC返回的顺序是不确定的。分页的时候就会出现“第一页看到的记录翻到第二页又出现了”这种诡异现象。解决办法是加一个唯一的次级排序字段db.Order(created_at DESC, id DESC)这样即使时间相同也能保证顺序稳定。再说性能。排序操作如果无法利用索引数据库就需要把结果集全部加载到内存里做排序filesort数据量大的时候非常慢。要让排序走索引需要满足几个条件排序字段上有索引排序方向一致要么全ASC要么全DESCWHERE条件中用到的字段和排序字段能组成联合索引的最左前缀举个例子如果经常执行这样的查询SELECT * FROM orders WHERE user_id 123 ORDER BY created_at DESC那么建立一个(user_id, created_at)的联合索引就能同时加速过滤和排序。在gorm里动态拼接排序字段时要特别注意防止SQL注入因为Order()方法不会自动转义// 危险直接拼接用户输入 db.Order(sortField sortOrder) // 安全白名单校验 allowedFields : map[string]bool{created_at: true, price: true, id: true} if allowedFields[sortField] { db.Order(sortField DESC) }laravel的orderBy方法同样需要白名单校验原理是一样的。3. 完整实操流程与关键环节实现3.1 数据模型设计与索引规划在动手写查询之前先把表结构和索引定好。这是整个工程的地基地基没打好后面查询怎么写都快不起来。假设我们有一个商品表核心字段包括自增ID、标题、描述、价格、状态、所属分类、创建时间、更新时间、软删除标记。针对这个表我通常会规划以下索引索引名字段组合用途idx_status_created(status, created_at)按状态筛选时间排序idx_category_price(category_id, price)按分类筛选价格排序idx_title_prefix(title(20))标题前缀搜索idx_deleted(deleted_at)软删除过滤索引不是越多越好。每个索引都会增加写入时的开销因为插入和更新数据时数据库需要同步维护所有索引。我的经验是单表索引数量控制在5个以内优先保证高频查询路径的覆盖。在gorm里定义模型时可以通过tag指定索引type Product struct { ID uint gorm:primaryKey Title string gorm:size:200;index:idx_title_prefix,length:20 Price float64 gorm:type:decimal(10,2) Status int gorm:default:1;index:idx_status_created,priority:1 CategoryID uint gorm:index:idx_category_price,priority:1 CreatedAt time.Time gorm:index:idx_status_created,priority:2 UpdatedAt time.Time DeletedAt gorm.DeletedAt gorm:index }laravel的迁移文件里写法类似用$table-index()方法声明。3.2 查询构造的完整实现现在进入核心部分。假设我们要实现一个商品列表接口支持以下筛选条件标题模糊搜索可选状态筛选可选支持多选价格区间可选分类筛选可选排除指定ID可选排序字段和方向可选默认按创建时间倒序分页先看gorm版本的Repository层实现func (r *ProductRepo) List(params ProductQueryParams) ([]Product, int64, error) { query : r.db.Model(Product{}) // 标题模糊搜索使用前缀匹配以利用索引 if params.Title ! { query query.Where(title LIKE ?, params.Title%) } // 状态多选用IN查询 if len(params.Statuses) 0 { query query.Where(status IN ?, params.Statuses) } // 价格区间 if params.MinPrice 0 { query query.Where(price ?, params.MinPrice) } if params.MaxPrice 0 { query query.Where(price ?, params.MaxPrice) } // 分类筛选 if params.CategoryID 0 { query query.Where(category_id ?, params.CategoryID) } // 排除指定ID if len(params.ExcludeIDs) 0 { query query.Not(id IN ?, params.ExcludeIDs) } // 统计总数在分页之前 var total int64 if err : query.Count(total).Error; err ! nil { return nil, 0, err } // 排序白名单校验 sortField : created_at sortOrder : DESC allowedSort : map[string]bool{created_at: true, price: true, id: true} if allowedSort[params.SortField] { sortField params.SortField } if params.SortOrder asc { sortOrder ASC } query query.Order(sortField sortOrder , id DESC) // 分页 offset : (params.Page - 1) * params.PageSize query query.Offset(offset).Limit(params.PageSize) var products []Product if err : query.Find(products).Error; err ! nil { return nil, 0, err } return products, total, nil }再看laravel版本的等价实现public function list(array $params) { $query Product::query(); // 标题模糊搜索 if (!empty($params[title])) { $query-where(title, like, $params[title] . %); } // 状态多选 if (!empty($params[statuses])) { $query-whereIn(status, $params[statuses]); } // 价格区间 if (!empty($params[min_price])) { $query-where(price, , $params[min_price]); } if (!empty($params[max_price])) { $query-where(price, , $params[max_price]); } // 分类筛选 if (!empty($params[category_id])) { $query-where(category_id, $params[category_id]); } // 排除指定ID if (!empty($params[exclude_ids])) { $query-whereNotIn(id, $params[exclude_ids]); } // 总数 $total $query-count(); // 排序 $sortField in_array($params[sort_field] ?? , [created_at, price, id]) ? $params[sort_field] : created_at; $sortOrder ($params[sort_order] ?? desc) asc ? asc : desc; $query-orderBy($sortField, $sortOrder)-orderBy(id, desc); // 分页 $page max(1, (int)($params[page] ?? 1)); $pageSize min(100, max(1, (int)($params[page_size] ?? 20))); $products $query-offset(($page - 1) * $pageSize)-limit($pageSize)-get(); return [list $products, total $total]; }两段代码的逻辑完全一致只是语法不同。注意几个关键细节第一Count操作要在Offset和Limit之前执行否则统计的是分页后的数量不是总数。第二排序字段一定要做白名单校验。我见过有人直接把前端传的sort_field拼进Order里结果被人用id; DROP TABLE这种输入攻击了。虽然现代ORM有一定的防护但白名单是最稳妥的做法。第三分页参数要做边界处理。page最小为1page_size要设上限比如100防止有人传page_size999999把数据库拖垮。3.3 新增操作的参数校验与返回值处理新增看起来简单但有几个细节容易出问题。gorm版本func (r *ProductRepo) Create(product *Product) error { return r.db.Create(product).Error }laravel版本public function create(array $data) { return Product::create($data); }新增操作的核心不在代码本身而在于参数校验和默认值填充。我的习惯是在Service层做这些事情检查必填字段是否为空检查价格是否为正数检查分类ID是否存在填充默认状态值记录创建人信息这些校验如果放在Controller里复用性差放在Model的钩子函数里又不够灵活。放在Service层是最合适的。另外新增成功后要返回完整的记录包含数据库生成的ID和创建时间而不是只返回一个成功标志。前端拿到ID后可能需要跳转到详情页或者做后续的关联操作。3.4 更新操作的字段白名单与并发控制更新操作最大的坑是误更新。比如用户只想改标题结果代码里把整个对象都更新了状态、价格全被覆盖成了默认值。gorm里用Updates配合结构体时零值字段会被忽略// 只会更新非零值字段 db.Model(product).Updates(Product{Title: 新标题, Price: 99.9})但如果确实需要把某个字段更新为零值就得用Map或者Select指定字段// 明确指定要更新的字段 db.Model(product).Select(title, price).Updates(Product{Title: 新标题, Price: 0})laravel里用update方法时只有传入的字段会被更新$product-update([title 新标题, price 0]);并发控制是另一个容易被忽略的点。如果两个请求同时更新同一条记录后提交的会覆盖先提交的。解决办法是加乐观锁用版本号字段// 更新时检查版本号 result : db.Model(Product{}). Where(id ? AND version ?, product.ID, product.Version). Updates(map[string]interface{}{ title: product.Title, version: product.Version 1, }) if result.RowsAffected 0 { return errors.New(数据已被其他请求修改请刷新后重试) }3.5 删除操作的软删除与硬删除选择删除分两种软删除和硬删除。软删除是给记录打一个删除标记数据还在表里硬删除是直接从表里移除。绝大多数业务场景应该用软删除。原因很简单用户误删了可以恢复数据分析时还能看到历史记录关联数据不会因为主记录消失而变成孤儿。gorm内置了软删除支持只要模型里有DeletedAt字段type Product struct { // ... DeletedAt gorm.DeletedAt gorm:index } // 软删除 db.Delete(product) // 查询时会自动排除软删除的记录 db.Find(products) // 如果要查包含已删除的 db.Unscoped().Find(products)laravel里需要手动实现软删除用SoftDeletestraituse Illuminate\Database\Eloquent\SoftDeletes; class Product extends Model { use SoftDeletes; }硬删除只应该在以下场景使用数据确实没有任何保留价值比如临时日志、数据量太大需要定期清理、合规要求必须彻底删除。硬删除前一定要做好备份或者先软删除观察一段时间再物理清理。4. 常见问题与排查技巧实录4.1 查询结果不符合预期的排查思路这是最高频的问题。明明条件写对了返回的数据就是不对。我的排查顺序是这样的第一步打印实际执行的SQL。gorm里开启Debug模式db.Debug().Where(...).Find(results)laravel里用toSql()或dd()$query-toSql(); // 或者 DB::listen(function ($query) { Log::info($query-sql, $query-bindings); });看到真实SQL之后问题往往一目了然。我遇到过很多次“条件没生效”的情况一看SQL发现是Where链式调用时变量覆盖了前面的条件被后面的覆盖掉了。第二步检查参数类型。比如前端传过来的价格是字符串99.9后端没做类型转换直接拼进查询数据库在做比较时可能触发隐式类型转换导致索引失效或者结果不准确。第三步检查NULL值。前面说过NULL在SQL里很特殊。如果某个条件字段允许为NULL而查询时用了!或NOT IN结果可能和预期不一致。4.2 分页数据重复或丢失的定位方法分页问题的根源几乎都是排序不稳定。排查方法检查排序字段是否有重复值检查是否加了唯一的次级排序字段检查分页参数是否正确offset计算有没有错一个实用的验证方法是连续请求第一页和第二页把两页的ID合并去重看总数是否等于page_size * 2。如果少了说明有重复如果多了说明有遗漏。4.3 常见问题速查表问题现象可能原因排查方法解决方案LIKE查询不走索引使用了全模糊匹配EXPLAIN查看执行计划改为前缀匹配或引入全文检索分页数据重复排序字段有重复值检查排序字段唯一性加ID作为次级排序更新影响了所有行WHERE条件缺失或错误打印SQL检查WHERE子句确保WHERE条件正确加LIMIT保护软删除数据仍被查出查询未启用软删除过滤检查模型是否配置软删除确认DeletedAt字段和查询作用域并发更新数据错乱缺少乐观锁模拟并发请求测试加版本号字段做乐观锁查询超时缺少索引或全表扫描EXPLAIN分析补充索引或优化查询条件参数注入风险直接拼接用户输入代码审查使用参数占位符白名单校验4.4 几个我踩过的坑和对应技巧坑一LIKE的转义问题。用户搜索“50%折扣”结果%被当成通配符搜出来一堆不相关的东西。解决办法是在拼接前转义特殊字符keyword : strings.ReplaceAll(searchTerm, %, \\%) keyword strings.ReplaceAll(keyword, _, \\_)坑二批量删除时的条件遗漏。有次写批量删除代码是db.Where(id IN ?, ids).Delete(Product{})测试时传了空数组结果IN ()变成了无条件删除整张表被清空了。后来我养成了习惯批量操作前先检查参数if len(ids) 0 { return errors.New(请选择要删除的记录) }坑三Order字段的SQL注入。前面提过这里再强调一次。永远不要相信前端传过来的排序字段一定要用白名单过滤。坑四Count和Find的顺序。有次先执行了Limit再执行Count导致统计的总数永远是分页大小。正确的顺序是先Count再分页。坑五软删除和唯一索引的冲突。如果某个字段有唯一索引软删除后再次插入相同值会报冲突因为软删除的记录还在表里。解决办法是把唯一索引改成包含删除标记的联合索引或者用时间戳作为删除标记。5. 工程化收尾与个人经验补充5.1 查询参数的结构化封装当筛选条件越来越多时把参数散落在方法签名里会变得难以维护。我的做法是定义一个查询参数结构体type ProductQueryParams struct { Title string Statuses []int MinPrice float64 MaxPrice float64 CategoryID uint ExcludeIDs []int SortField string SortOrder string Page int PageSize int }这样做的好处是Controller层只需要把HTTP参数绑定到这个结构体上Service层和Repository层都接收同一个结构体。加一个新条件时只需要改结构体定义和Repository里的拼接逻辑其他层不用动。5.2 接口返回结构的统一约定列表接口的返回结构我习惯固定为{ code: 0, message: success, data: { list: [], total: 100, page: 1, page_size: 20 } }total是满足条件的总记录数不是当前页的数量。前端根据total和page_size计算总页数。这个约定一旦定下来所有列表接口都保持一致前端写一次分页组件就能复用。5.3 关于性能的一点个人体会多条件查询的性能优化八成的功夫在索引设计上两成在查询写法上。我见过太多人花大量时间优化SQL语句结果一看表结构该建的索引一个没建。我的习惯是每写一个查询接口先用EXPLAIN跑一遍看有没有全表扫描看有没有用到索引。如果发现type是ALL那就说明有问题要么加索引要么改查询条件。另外不要过早优化。数据量小的时候怎么写都快。等到数据量上来了再根据慢查询日志有针对性地优化。上来就搞分库分表、读写分离往往是过度设计。5.4 最后分享一个调试小技巧开发阶段我会在Repository层加一个开关开启后把所有执行的SQL和耗时都打到日志里if debugMode { db db.Debug() }配合慢查询阈值比如超过200ms就告警能快速定位到问题查询。上线后关掉Debug但保留慢查询日志。这个习惯帮我提前发现了好几个潜在的性能问题。代码写到最后拼的不是谁会的语法多而是谁对边界情况考虑得周全、谁对性能瓶颈有预判、谁的代码别人接手时能看懂。多条件查询这个场景恰好把这几点都覆盖到了值得花时间打磨。