动态写操作防线:条件为空时阻止全表更新与删除
事故防线: 动态 SQL 最危险的失败方式不是语法错误,而是条件被全部过滤后仍然生成合法的全表
UPDATE或DELETE。本文从一个批量状态修改接口出发,拆解参数校验、Criteria 校验、影响行数、事务和审计五层防线,并结合 MetaLite 当前源码说明它已经拦住了什么、仍需要补上什么。
假设后台提供批量关闭订单接口:
closeOrders(tenantId, orderIds, reason);
最终生成:
UPDATE order_info
SET status = 'CLOSED'
WHERE tenant_id = ? AND id IN (...)
如果 tenantId 丢失、orderIds 为空,而动态 SQL 又把无效条件全部跳过,最终可能变成:
UPDATE order_info SET status = 'CLOSED'
这不是慢查询,而是数据事故。
一、为什么“对象不为 null”远远不够
很多公共 DAO 会做这样的校验:
assert criteria != null;
但下面这个对象同样不为 null:
Criteria criteria = Criteria.where();
它是否包含有效条件,要看内部条件树,而不是 Java 引用是否存在。
同理,参数 DTO 存在也不代表关键字段有效:
- 空字符串被 trim 后消失;
- 空集合对应的
IN被跳过; - 非法枚举被转换为
null; - 权限过滤没有得到任何组织 ID;
- 软删除和租户条件漏传。
二、MetaLite 当前已经具备的两层校验
MetaLite updateByCriteria 会检查 Criteria 和 Update 不为空,并要求更新字段集合非空:
public int updateByCriteria(Criteria criteria, Update update) {
AssertUtil.paramNotNull(criteria, "criteria");
AssertUtil.paramNotNull(update, "update");
AssertUtil.paramNotEmpty(update.getSetMap(), "update.setMap");
// 生成 UPDATE、SET、WHERE
}
它能够阻止:
- 调用方完全没有传查询对象;
- 没有任何更新字段却执行空更新;
IN传入空集合,因为Criteria.in会提前拒绝。
deleteByCriteria 也要求查询对象不为空。
这些校验减少了低级错误,但当前源码仍有一个必须如实说明的边界:仅校验 Criteria 非 null,不能证明其内部一定包含有效限制条件。
三、企业 ORM 需要显式的全表操作语义
通用 DAO 不应通过“猜测条件”决定能否全表更新。更可靠的设计是把危险操作变得显式。
例如默认拒绝空条件:
if (criteria.isEmpty()) {
throw new IllegalStateException("禁止无条件更新");
}
确实需要全表操作时,不应该伪造一个恒真条件,而应使用明确入口:
dao.updateAll(update, FullTableOperationToken.confirmed());
这样代码搜索、审查和审计都能找到所有高风险操作。
四、只判断条件数量仍然不够
下面的条件数量不是零,但约束能力可能很弱:
WHERE status IS NOT NULL
它可能匹配 99% 的数据。因此完整防线还需要考虑影响范围。
1. 更新前预估数量
long count = orderDao.countByCriteria(criteria);
AssertUtil.stateCheck(count <= 1000, "单次最多处理1000条");
需要注意预估查询和真正更新之间存在并发时间窗,不能把它当成绝对锁。
2. 更新后检查影响行数
int affected = orderDao.updateByCriteria(criteria, update);
if (affected > expectedMax) {
throw new IllegalStateException("影响行数超过阈值");
}
这段检查必须位于事务中,抛出异常后才能回滚。
3. 高风险操作分批执行
通过主键分页、任务批次和进度记录控制影响范围,避免一次事务锁住整张表。
五、租户条件和数据权限不能靠调用方自觉
最危险的遗漏往往不是业务条件,而是安全条件。
如果每个 Service 都手工追加:
criteria.eq(OrderEntity::getTenantId, tenantId);
迟早会有一个入口忘记。企业底座应考虑在 DAO、路由或统一查询构建阶段注入租户范围,并为绕过机制设置严格审计。
数据权限也是同样的问题:前端没有传组织 ID,不应解释为查询全部组织;权限计算得到空集合,也不应跳过条件。
六、删除比更新更需要恢复方案
更新错误有时还能根据旧字段恢复,物理删除往往无法逆转。高风险删除至少需要:
- 优先逻辑删除;
- 保存操作者、请求 ID 和条件摘要;
- 删除前导出主键清单或快照;
- 限制单次影响行数;
- 在事务中执行影响行数检查;
- 提供恢复脚本并演练。
“有数据库备份”不等于能快速恢复某次误删除。真正可用的恢复方案必须知道删了哪些行以及如何定位。
七、建议给 MetaLite 补上的公共能力
基于当前源码,下一步最有价值的增强不是再增加一个 CRUD 方法,而是补齐写安全策略:
Criteria.isEmpty()与有效条件数量;- 默认禁止空条件更新和删除;
- 全表操作使用独立、显式 API;
- DAO 支持最大影响行数策略;
- 统一记录逻辑表、路由结果、条件摘要和影响行数;
- 为租户、数据权限条件提供不可遗漏的扩展点;
- 对批量操作提供分批和恢复模板。
这也是源码分析的价值:不是把现有实现包装成完美答案,而是明确已经建立的防线和仍需演进的边界。
八、上线前的事故预防清单
- 空 DTO、空字符串、空集合均有测试;
-
Criteria内无有效条件时默认拒绝执行; - 更新字段集合不能为空;
- 租户与数据权限由公共机制注入;
- 批量更新、删除设置影响行数阈值;
- 阈值检查位于可回滚事务中;
- 日志记录条件摘要而不泄露敏感值;
- 物理删除具有主键清单、备份和恢复流程。
九、结论
动态 SQL 最可怕的不是报错,而是生成了一条完全合法、执行成功、影响全表的语句。
MetaLite 当前对空更新集合和空 IN 已经进行入口校验,但 Criteria 非空不等于查询范围安全。企业级 ORM 真正应该做到的是:默认拒绝模糊的危险行为,让全表操作显式化,让影响范围可度量,让事故能够回滚和恢复。
框架简介
MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。
源码基线
JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。
作者简介
15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。
持续更新
MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。
在线演示
演示地址: https://admin.metalite.top/
演示账号: guess
演示密码: admin@2026

802

被折叠的 条评论
为什么被折叠?



