前一阵我在设计一个内部运营系统时,需求列表里就写着两个汉字:“查询”。没有原型,没有字段表,没有说明要查哪些范围,也没有说清楚查完之后是展示、导出还是统计。我盯着这个标题愣了几秒,然后意识到,这可能是整个项目里最容易被低估的功能:数据量大起来,条件一多,查询模块会同时牵扯接口设计、SQL 性能、数据权限和前端交互,任何一个环节偷懒,验收测试时都会被翻出来重做。
这篇文章不打算讲某个特定框架的魔法,而是想把我这些年做通用查询模块的完整思路拆开来说清楚:从需求边界确认、查询条件建模、动态 SQL 拼装,到权限与脱敏、慢查询排查,再到上线前的检查清单。我尽量用实际项目的语言来讲,适合正在做后台管理系统的后端开发、全栈开发,以及负责牵头梳理需求的同学。你拿到的标题哪怕只有“查询”两个字,按这条链路走一遍,也能交付得比较扎实。
1. 需求梳理:查询列表和导出统计,是三种完全不同的工作量
1.1 先分清“点了一下按钮”背后的真实任务
很多查询功能的返工,不是写代码的人不行,而是需求方自己也没想清楚。业务方说“我要一个查询页面”,通常包含下面几类潜台词:
第一类是“列表查询”:输入若干条件,点查询,底下表格展示符合条件的数据。第二类是“导出”:把当前查询结果按 Excel 模板导出去,甚至要按汇总行展开明细。第三类是“统计”:同一个查询条件,不仅要有明细,还要给总数、分布、环比这些指标。这三类的数据量级和实现成本差别很大,如果把三类全塞进同一个接口,容易出现一个接口又查明细又做汇总,数据库压力分分钟爆掉。
所以我在拿到“查询”需求时,第一件事不是建表,而是约着业务方做一次不超过半小时的澄清。我会直接问:你这张列表最多需要展示多少条?看的人会不会按某些固定条件反复查?需不需要导出?导出的模板是固定列还是跟着查询结果变化?如果没有导出,是否也需要在列表下方看聚合数字?这些问题问完,需求大体就能分成“一个简单列表”“一个列表加导出”“一个查询分析页面”三个级别。
1.2 把“能查什么”固化成一张字段清单
查询需求最怕的是口头描述。为了让后端不返工,我会把需求拆成一张查询字段清单,包含字段名、字段类型、允许的查询方式、取值范围,以及是否必填。举个例子,一个订单列表可能长这样:
| 字段名 | 类型 | 常用查询方式 | 默认值 |
|---|---|---|---|
| orderNo | 字符串 | 精确 / 模糊 | 空 |
| customerName | 字符串 | 模糊 | 空 |
| orderStatus | 整型集合 | 多选 IN | 默认有效状态 |
| createdTime | 时间范围 | between | 默认当天往前 3 个月 |
| channel | 字符串 | 等值 / 多选 | 空 |
这张表同时要定义字段的“业务口径”,比如订单金额查询是含税还是不含税,时间范围是订单创建时间还是支付完成时间。口径不统一做出来的查询,业务方测试时一眼就能看出金额和财务对不上。所以字段清单不仅给开发用,也应该发给业务方做一次签字确认。
1.3 把边界条件也写进验收标准
查询模块的返工大多藏在边界条件里。我在列表需求评审中还会重点确认下面这些场景:
- 不输入任何条件,点查询是否允许返回全量数据?很多 toB 系统会限制必须至少有一个条件,避免用户一次把全表拉出来。
- 查询条件之间的关系是全部 and,还是某些字段要支持 or?
- 如果用户输入了空格,或者前后有空格,是去掉空格还是按空格原样匹配?
- 点击“重置”后,是恢复默认条件,还是清空所有条件?
- 列表排序是否允许用户点击表头切换?默认的排序规则是什么?
- 总数统计是返回完整 count,还是只返回当前分页的总数近似值?
说实话,需求文档里如果没有明确这些边界,开发阶段就会反复猜。猜对了是运气,猜错了就是测试和业务一起找上门。与其后面补救,不如一开始就把这些作为验收条件写下来。项目标题可以只写“查询”,但测试用例不能只写“能查到数据就算通过”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 条件建模:把查询请求设计成一个规范的对象
2.1 为何不能用“零散参数”一路传到后端
查询功能最简单粗暴的写法,是前端把每个条件都作为独立 query 参数传过来,后端方法签名也照着参数列表写,比如 list(String orderNo, String customerName, Integer orderStatus, Date startTime, Date endTime, Integer page, Integer size)。这种写法在条件只有两个时完全没问题,但条件一旦超过五六个,代码就开始失控。新增一个查询条件,前端改一处,Controller 改一处,Service 改一处,Mapper 参数对象改一处,测试用例也要同步改。这种日子我过了很久,后来痛定思痛,统一改成“查询请求对象”。
一个合格的查询请求对象,需要把查询条件、分页、排序分开,还要考虑条件本身的结构化表达。下面这个 JSON 是我在多个项目中调整后的通用形态:
json复制{
"query": {
"conditions": [
{ "field": "orderNo", "op": "like", "value": "SO2024" },
{ "field": "orderStatus", "op": "in", "values": [1, 2] },
{ "field": "createdTime", "op": "between", "start": "2024-01-01 00:00:00", "end": "2024-03-31 23:59:59" }
],
"logic": "and"
},
"sort": { "field": "createdTime", "order": "desc" },
"page": 1,
"pageSize": 20
}
有人会觉得这样比平铺参数复杂。但它的好处是:后端可以把 conditions 数组作为元数据结构循环处理,每新增一个可查询字段,只需要注册字段到 SQL 映射关系,不需要为这个字段单独写一套条件判断逻辑。同时,前端组件也可以根据条件字段配置动态生成查询表单,而不是每张页面都手工拼参数。
2.2 操作符不要做得太泛,够用且安全就好
常见的查询操作符主要就是这么几个:eq 等于、ne 不等于、like 模糊匹配、in 多值、between 区间、isNull 为空。我不建议把操作符做得过于自由,尤其不要开放“执行任意 SQL”或者“传一个完整的过滤表达式进来”,那样后端很快就会被传成一个裸 SQL 执行器,权限和注入都没法控制。
我见过有的团队喜欢做成通用“查询构建器”,支持前端传 {"field":"xxx","op":"gt","value":100} 这种 JSON,这本身没问题,但后端必须持有一份“白名单校验器”。查询条件里的 field 不是直接拼进 SQL 的列名,而是先映射到一个受控的列或者查询表达式,这样即使前端传了 password、secret_key 这类字段名,后端也会直接拒绝。
2.3 分页与排序:把用户可能踩的坑提前拦住
分页参数也需要有明确的约束。page 通常从 1 开始,pageSize 需要设置上限,比如最多 100,默认 20。很多系统允许用户一次导出几万条,那是导出任务,不应该和页面列表共用一个分页接口。排序字段也要做映射白名单,不能让前端传一个数据库真实列名,比如 field: "role_id"。前端传的是排序显示值,比如 createdTime,后端映射到安全且能走索引的列。
我还会要求后端在入口处做一次统一的参数校验,而不是等 SQL 执行后才发现参数有问题。这种校验可以用参数校验框架,也可以用简单的 private 方法。核心检查点是:条件数组为空时是否允许查询、分页大小是否越界、时间范围是否开始时间大于结束时间。不要小看这些细节,很多慢查询就是由“时间范围选错,用户改了一个超大跨度”触发的。
3. SQL 动态拼装:把控制权从“字符串拼接”手里抢回来
3.1 最常见的问题出在 if 判断和空值处理上
动态 SQL 是查询模块最容易写乱的地方。常见做法是字符串拼接:
code复制SELECT * FROM orders
WHERE deleted = 0
AND order_no = #{orderNo}
如果 orderNo 为空,就会抛语法问题,所以很多人会加一个 <if> 判断。可是当条件多到十几个时,Mapper XML 里就会塞满 <where> <if> <foreach>,看起来很长。
更麻烦的是空值语义。比如用户在下拉框里什么都没选,传给我们的是 [] 空数组;用户清空了时间字段,传给我们的是 null。如果 XML 里用了 != null,空数组往往会被当成不为空,最后拼出 and status in (),直接语法错误。所以我的建议很朴素:在后端 Service 层统一把“空字符串、空数组、纯空白字符串”都规范化成 null,再交给 SQL 层判断。不要把空值判断的压力全推给 Mapper,那是把不稳定的因素埋到了最底层。
3.2 用一套“字段-操作符-值”三级映射规则
我在实践中会把动态 SQL 再抽象一层,做成类似这样的规则表:
| 字段标识 | 映射到的 SQL 片段 | 说明 |
|---|---|---|
| orderNo | order_no |
精确匹配 |
| customerName | customer_name |
模糊匹配时给 value 拼接 % |
| orderStatus | status |
需要映射 in 列表 |
| createdTime | created_at |
时间范围列 |
后端在收到请求后,先遍历 conditions,对每个字段做三步处理:第一步,检查字段标识是否在白名单映射表里;第二步,按操作符生成 SQL 片段,不允许的 op 直接抛参数异常;第三步,生成参数占位符而不是直接拼接值。这样做的目的是让 SQL 注入问题在框架层面就消失,而不是靠某一个字段的谨慎写。
下面是一段基于 MyBatis 的动态 SQL 示例,可以作为参考:
xml复制<select id="searchOrders" resultType="OrderDTO">
SELECT id, order_no, customer_name, status, created_at
FROM orders
<where>
deleted = 0
<if test="param.orderNo != null">
AND order_no = #{param.orderNo}
</if>
<if test="param.customerName != null">
AND customer_name LIKE CONCAT('%', #{param.customerName}, '%')
</if>
<if test="param.statusList != null and param.statusList.size > 0">
AND status IN
<foreach collection="param.statusList" item="s" open="(" separator="," close=")">
#{s}
</foreach>
</if>
<if test="param.startTime != null">
AND created_at >= #{param.startTime}
</if>
<if test="param.endTime != null">
AND created_at <= #{param.endTime}
</if>
</where>
ORDER BY created_at DESC
LIMIT #{param.offset}, #{param.pageSize}
</select>
很多新手会在 created_at 上漏写时间范围条件,导致用户选了时间段,结果列表里还是查出了所有历史数据。这里务必要记得,时间范围比较方向不能写反,startTime 对应的是大于等于,endTime 对应的是小于等于;如果一天结束时间要包含到 23:59:59,建议在参数层统一做处理。
3.3 like 模糊匹配和索引失效的老问题
模糊查询是让 SQL 慢下来的常见元凶。如果业务允许只做“前缀匹配”,业务方输入几个字,你拼成 LIKE '关键字%',那还能勉强利用索引。但是很多系统为了体验做的是“包含匹配”,无论输入的内容在字符串哪个位置都会返回,那数据库就走不了 B+ 树索引,只能全表扫描。
如果数据量在几十万行以下,偶尔一次包含匹配也能承受;一旦单表数据量上千万,接口就危险了。我的建议是:
- 能精确就不模糊,能前缀模糊就不包含模糊。
- 必须做包含匹配时,应该考虑搜索引擎或全文索引,不能在业务高峰期用慢 SQL 硬扛。
- 如果暂时不想引入重组件,至少限制条件不能单独使用,比如必须携带时间范围。这样就算走不了订单号的索引,也能借助时间字段缩小扫描范围。
其实这背后是一个“索引友好度”的取舍。查询模块往往看着简单,但一个不加时间限制的模糊匹配,就可能把数据库打挂。我后面专门用一节讲性能问题,这个点会再次提到。
4. 数据权限与敏感字段:查询接口最容易被要求返工的地方
4.1 行级数据权限不应该散落在每个 Service 方法里
后台管理系统一般都有角色和数据范围的概念。比如“张三只能看本部门数据,李四能看全公司数据,王五只能看自己创建的数据”。最开始不少项目会写成这样:每个查询方法里都拼一段 WHERE dept_id = ?,而且这段代码在不同 Service 中复制粘贴。
这种做法的最大问题是,一旦数据权限规则调整,所有 Service 都要跟着改,总会漏掉一两个查询方法。部门经理被调到另一个部门后,登录旧系统还能看到旧部门数据,这种 bug 从体验上看就是“越权”。
我更推荐在进入 Service 之前,先把当前登录用户的可视范围解析成一个统一的数据权限对象。这个对象至少要包含三部分:可见的组织机构范围、可见的数据归属人范围、是否包含子部门。然后在查询入口统一转换成 SQL 条件。这样业务方法不需要感知权限细节,只要调用查询基础能力,框架会自动拼上数据权限条件。
举个例子,用户属于 A 部门,且角色设置为“本部门及以下”,后端就可以自动生成:
code复制AND org_id IN (
SELECT id FROM sys_org WHERE org_path LIKE '/root/A/%'
OR id = #{currentOrgId}
)
这背后的 org_path 可以用物化路径来维护,也可以用闭包表。重点是,这条规则必须在所有查询列表、导出、详情接口上生效,不能只针对主数据表生效,一旦查询涉及关联表,也要记得把数据权限条件关联到正确的所属机构列上。
4.2 列级脱敏:不要在接口返回后做全局替换
很多系统涉及手机号、身份证号等敏感信息,列表里需要做脱敏展示。常见的错误做法是:SQL 先把完整数据查出来,然后在内存中写一个工具类,判断用户权限,如果不是管理员就把手机号中间四位替换成 *。
这个做法在小系统里能工作,但缺陷很明显:敏感数据已经进入了应用层的内存和日志,如果应用被攻破或日志被采集走,脱敏就形同虚设。更合理的做法是,脱敏尽量在数据库查询阶段或 ORM 映射阶段完成。以手机号为例,可以在 SQL 里直接写成:
sql复制SELECT
CASE WHEN :hasDecryptPermission = 1
THEN phone
ELSE CONCAT(LEFT(phone,3), '****', RIGHT(phone,4))
END AS phone_mask
FROM users
如果项目里不允许直接在 SQL 里做这个判断,那也至少要在 DTO 转换层做,而不是把所有字段一股脑返回给前端再靠前端隐藏。要知道前端拿到完整字段就意味着接口可以被人绕过页面直接调用,手机号依然会泄露。开发者要明白一个原则:敏感字段没有展示权限,就不应该出现在接口响应里。
4.3 越权查询的测试用例,应该像登录用例一样被重视
查询模块的权限测试,很多团队只测“已登录”“未登录”两个场景,这远远不够。我在项目验收时一定会要求补上这些用例:
- 用户 A 查询订单时,把请求里的
orgId改成另一个单位的 id,是否还能查到数据?如果后端强制使用当前登录人的机构,就不会被这个参数影响。 - 用户 A 直接构造一个查询详情接口,把 id 改成另一个人的数据 id,是否能看到?详情接口也必须做归属校验。
- 普通用户访问管理员列表接口,返回结构是否被正确限制,而不是只返回一个空列表,但把字段结构暴露了。
- 导出接口是否复用了列表接口的数据权限?很多系统列表有做行级权限,导出忘了加,全部数据被导出,这是严重事故。
权限不是列表查询接口独有的需求,但往往在查询模块里最容易测出问题。因为查询条件可以任意组合,攻击者最常做的就是把参数改一改尝试越权。所以我建议开发时把权限校验放在查询入口的统一逻辑中,而不是依赖各个方法自行判断。
5. 一次慢查询的完整排查:从 8 秒降到 120 毫秒
5.1 现象与定位:不是所有慢 SQL 都写在慢查询日志里
有次我们上线了一个订单查询列表,测试环境数据量只有几十万行,接口响应一直正常。上了生产后,数据量过了千万行,业务方开始在群里反馈“查询一次要转圈好久”,后来直接发来截图,接口耗时 8 秒。
我第一步不是看代码,而是先找数据库慢查询日志,把实际执行的那条 SQL 捞出来。慢查询日志里往往会有很多次执行,我们挑了一个耗时最高的样本,发现它长这样:
sql复制SELECT *
FROM orders o
LEFT JOIN order_items i ON o.id = i.order_id
WHERE o.deleted = 0
AND (o.order_no LIKE '%SO2024%' OR o.customer_name LIKE '%张%')
ORDER BY o.created_at DESC
LIMIT 100000, 20;
这条 SQL 一眼看去就存在好几个问题。首先是查询条件是包含模糊匹配,%关键字% 没办法走索引。其次是左连接查询让 MySQL 不得不先把大量中间的关联结果排好序,然后再跳过 10 万行取 20 行,这个跳过动作本身就是巨大的损耗。最后我还注意到没有按数据权限去限制 org_id,说明开发者当时可能只在一个租户测试,没有覆盖多租户千万级数据的场景。
5.2 打开执行计划,逐项解决“为什么慢”
排查慢 SQL 最直接的工具是 EXPLAIN。我把上面那条 SQL 复制出来跑了一遍执行计划,关键指标是这样的:
| 指标 | 看到的结果 |
|---|---|
| type | ALL(全表扫描) |
| rows | 1200 万行 |
| Extra | Using where; Using temporary; Using filesort |
这意味着 MySQL 把整表都扫了一遍,还产生了临时表和文件排序。三个问题都得解决,只解决一个,效果都有限。
我先处理排序和深度分页:用户不可能在页面上翻到第 5000 页,但通过搜索引擎或内部系统自动翻页,确实可能把 LIMIT 偏移量推到很大。于是我把前端选页的分页逻辑,改成了基于排序字段的游标分页。也就是列表不再用“第几页”来定位,而是记住上一页最后一条记录的 created_at 和 id,下一页查询时直接在 SQL 里加上 WHERE created_at < ?,这样每一页都能命中索引,不需要跳过前 10 万行。
第二步处理索引:把 order_no 的包含匹配去掉,改成前缀匹配;业务方如果确实需要订单号中间片段,那就必须先用时间范围把数据收敛到一定量,再允许这种查询。同时在 (org_id, created_at, id) 上建了复合索引,让“当前机构+按时间排序”这个高频路径走索引。
第三步拆分列表页与详情查询:列表页只查订单主表字段,不在列表阶段关联 order_items 明细数据,每张订单对应的商品数量单独用一次批量查询 WHERE order_id IN (...) 再在应用层组装。这样既能减少大表 JOIN 的消耗,也不会让列表每一行都重复读取相同数据。
5.3 优化后的结果与技术沉淀
优化后的 SQL 简化为类似下面的结构:
sql复制SELECT id, order_no, customer_name, status, created_at
FROM orders
WHERE deleted = 0
AND org_id = ?
AND created_at < #{lastCursor}
ORDER BY created_at DESC, id DESC
LIMIT 20;
再跑一次执行计划,访问类型从 ALL 变成了 range,扫描行数直接落到了几百行,接口耗时从 8 秒降到了 120 毫秒左右。这个优化其实做起来不难,难的是上线前有没有想清楚索引和分页方案。很多项目在测试环境几十万行数据时“感觉还行”,上线后数据一涨就爆,所以查询模块最好提前用生产同量级的数据压一压。
这次排查之后,我把经验固化成了两条团队约定:一是所有列表接口不允许出现 LIMIT 和大的 offset 组合,支持翻页必须考虑游标方式;二是任何模糊查询如果要使用包含 %关键字%,必须和至少一个范围条件组合出现,否则在网关层就打回去。约定这种东西,光靠口头说没用,要落在代码 review 清单里才靠谱。
6. 查询接口上线前,我通常会做的三层检查
6.1 条件覆盖与参数边界检查
查询模块的测试最容易漏掉各种参数边界。我会让测试同学按照“每个字段至少测一遍正常值,一遍空值,一遍非法值”的思路来写用例。特别要注意下面这些:
- 数值型字段传负数、超长数字,字符串字段传 HTML 标签和单引号,会不会报错或者被解释为其他含义。
- 时间范围字段只传开始时间而不传结束时间、结束时间早于开始时间,程序是否能够正确提示。
- 字段值包含 Unicode 特殊字符,比如全角空格、emoji,是否影响查询,是否可能被截断。
page传入 0 或负数,pageSize传入 10000,是否会被统一限制到默认值。- 排序字段传非法值,是否会被拒绝而不是被拼进 SQL 导致报错。
这一层主要是做防御性检查。查询请求本质上是用户可控的入口,不要信任任何前端生成的参数。参数校验宁可严不要松,接口返回一个参数错误提示,比数据库抛一个异常要体面得多。
6.2 SQL 注入与数据权限回归
第二层检查我会单独放在“安全回归”里。最简单的注入测试方法是直接往请求参数里塞 /、单引号、注释符号等内容,再观察返回结果。像 % 和 _ 这样的 SQL 通配符也要考虑,如果用户输入 %,业务方本意是想查包含百分号的字符串,结果数据库把全部数据都匹配出来,这也算是一种“业务注入”问题。我在动态 SQL 拼装时,遇到 like 查询,会主动对通配符做转义处理。
数据权限的回归也不能只在列表接口上测。查询模块如果带着导出按钮,导出功能必须重新确认一遍,不能出现“列表只显示 20 条有权限,导出却导出了全部数据”的问题。还有详情接口、下拉框选项接口、统计接口,所有看似和查询相关的旁路能力,都应该纳入同一套权限策略。
6.3 可回放性和排障体验
今年我养成了一个新习惯:查询接口在入口处打印一条带唯一请求 ID 的简洁日志,里面包含查询条件、分页、排序字段、当前用户,以及最终的 SQL 参数列表。但是这里要非常小心,日志不能打印敏感字段,比如身份证号、手机号明文,脱敏后再打印。这么做的好处是,线上用户反馈“某笔单子查不到”的时候,我拿着 requestId 就能还原他的查询参数,而不是只能靠猜。
这套“可回放的请求参数”库在项目里可以很简单,不用引入额外组件,核心就是在大查询方法入口包一层 AOP 或者在过滤器里统一记录。排障的时候,把日志中的查询条件重新放到测试环境执行一次,出现频率高的慢日志自然就会浮出水面。查询功能看起来小,真正稳定运行起来,靠的往往是这些运维侧的细节。
我个人的体会是,查询模块不是一个“给个标题就能秒开”的功能。它藏在每个后台页面的列表里,但它又是用户每天点得最多的入口。今天多花一点时间把条件建模、权限注入和 SQL 性能问题想清楚,明天就能少很多“线上又卡了”“为什么他看得到我看不到”的麻烦。希望我踩过的这些坑,能帮你把下一次“查询”需求交付得更顺。
