48 KiB
FMS 通用保存接口(saveobjt)条件更新设计
状态:设计稿(已补齐首期实现约束) 涉及:
fms-api通用保存链路、fms-vue审批类动作
0. 一句话
给通用保存加「带条件的写入」——把「读 → 判断 → 写」的原子性交回给数据库, 让写入本身能表达「这一行必须还是我以为的样子,否则不写」; 再按表的业务粒度选择普通局部更新或字段冲突检测, 不把整行版本锁强加给所有表。
1. 背景
1.1 现状
/data/saveobjt 的定位方式只有主键:
// DbUtils.update —— fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java
String whereSql = keyColumns.stream()
.map(column -> quoteColumn(column) + " = ?")
.collect(Collectors.joining(" AND "));
String sql = "UPDATE " + table.quotedName()
+ " SET " + setSql
+ " WHERE " + whereSql;
也就是说,它是盲写:只要主键存在,写什么都行。
由此带来三个问题:
| 问题 | 表现 |
|---|---|
| 并发脏写 | 两人同时修改同一字段或提交整单快照,后一个覆盖前一个 |
| 重复提交 / 网络重试 | 请求发出、响应丢失,用户再点一次 → 写两遍 |
| 前置条件无处安放 | 「只有送审的才能通过」只能由调用方自己判断,判断与写入之间有窗口 |
这三个问题不是一回事,guards 只直接解决第三个;并发覆盖和重复执行要由不同层级的机制处理(见 §1.3)。
1.2 旧系统的教训(不适用新系统的审批)
g3hd 平台已有版本锁能力(/saveobject_Lock → BatchTable_Lock,
SET ..., b_version = b_version + 1 WHERE pk = ? AND b_version = ?),但:
saveobjtlockApi在整个前端没有任何一处调用- 审批中心
shcenter/index.js走的是无锁的saveobjtApi
所以旧系统的问题不是「加了锁但不够」,而是**「锁有却没用,审批是纯盲写」**。
新系统不是这样——审批动作已经不经过 saveobjt:
| 层 | 位置 | 现状 |
|---|---|---|
| 前端 | ApprovalDetailDrawer.vue 调 actWorkflowTaskApi |
走 /api/workflow/task/act,不是 saveobjtApi |
| 后端 | WorkflowRuntimeService |
UPDLOCK, ROWLOCK 锁行 + b_row_version 递增 + b_idempotency_key / b_request_id 幂等,全部副作用在同一事务;业务状态由 projectBusinessStatus 回写业务表 |
即「审批盲写」这个动机在新系统已经不成立。guards 的真实战场是另一处:
业务明细的增删改 vs 审批投影出来的业务状态。
审批由工作流服务改主表状态(它持有那一行的写锁),明细由
saveobjt写—— 两边争的是同一行,但明细这一侧目前没有任何前置条件。§3.2 / §3.3 的 demo 就是这个场景。
1.3 三个问题与分层机制
| 问题 | 机制 | 本设计 |
|---|---|---|
| 并发脏写(后写覆盖先写) | 局部更新、字段原值比较、整行版本锁三选一 | 按表 / 动作选择,不全局强制整行 version |
| 重复提交 / 网络重试 | 幂等键:请求带 request_id,服务端查重 |
不做,建议配套 |
| 前置条件无处安放 | guards(本文档) | ✅ |
guards 解决不了前两条——同一个请求原样重放两次,guard 两次都可能成立。
这两条在项目里都有现成先例可抄:wf_instance / wf_node_run 已经在用
b_row_version + UPDLOCK,wf_action.b_request_id 已经在做动作去重
(WorkflowRuntimeService 类注释 §12.1 / §12.2 / §12.3)。
建议后续按同一思路补进 saveobjt。这些机制是互补关系,不是替代:
局部更新减少无关字段冲突,字段原值比较防同字段覆盖,
整行版本锁用于确实需要整单一致的操作,幂等键防重复执行,guards 防非法状态转移。
1.4 推荐的并发分层
| 层级 | 适用对象 | 默认机制 | 是否允许并行修改不同字段 |
|---|---|---|---|
| 普通表 / 普通字段 | 字典、配置、互不关联的资料字段 | 只更新请求中提交的字段,不启用整行 version | 允许;不同字段互不覆盖 |
| 关键字段 | 金额、数量、名称等不允许被别人静默覆盖的字段 | 行内 $original 原值比较,比较提交的字段而不是整行 |
允许;同一字段被改过则失败 |
| 单据的跨表条件 | 表头状态 + 明细增删改 | guards 子查询把「状态判断」和「明细写入」压进同一条语句 | 允许;状态已变则失败 |
| 整单操作 | 状态迁移、整单导入、不可拆分的批处理 | b_row_version 整行版本锁(同样写在 $original 里) |
不允许,冲突即失败 |
该分层解决了整行 version 的误冲突:甲改金额、乙改备注时,普通局部更新可以同时成功;
甲乙同时改金额时,携带该字段 $original 的请求只有一个成功。
单据的「审批 vs 明细」靠 guard 的子查询与写入同语句完成,不引入额外的锁策略(§11)。
2. 目标与非目标
2.1 目标
- 写入可以携带前置条件,条件不成立即失败并整体回滚
- 通用 SQL 层不理解业务语义;条件由调用方以 guards 声明,服务端只负责拼装与回查
- 失败能给出准确、可直接展示给用户的提示
- 普通表向后兼容:不传条件的调用方行为完全不变
- 普通字段允许无关字段并行修改,同一字段可按需做原值比较
2.2 非目标
- 不做 SQL 注入防护。内部系统、全信任环境,条件允许是裸 SQL 片段(已确认)
- 不靠裸 guards 保证跨行 / 聚合条件的并发安全(见 §7、§8);现算的判据要先物化
- 不提供单据级串行保证——guards 保证「写入那一刻条件成立」,不保证整个事务期间状态不变(§11)
- 不替代流程引擎,不接管流转计算
- 不把整行乐观锁作为全局默认——仅对整单操作或明确要求整单一致的动作启用
- 本次不实现请求幂等(§1.3)——按 workflow 现有
request_id方案后续补入
2.3 本次实现的硬约束
以下不是"后续优化",而是实现前必须确定的行为:
- 目标不存在不属于 guard 失败:更新 / 删除命中 0 行时,先区分目标行不存在与 guard 不成立;目标不存在沿用普通保存失败(
2001),只有目标存在但 guard 不成立才返回2002。 - 带 guards 的删除只允许唯一定位:本次
guards.deletes的每行必须只包含完整key_field(包括复合键),命中 0 或 1 行;按非唯一条件批量删除的行,以及delete_target/ 依赖删除旁路,不能携带guards.deletes。 - insert 的请求值一律参数化:guard 占位符替换为
?,值加入 PreparedStatement 参数,不拼 SQL 字面量。guard 自身仍是调用方提供的 SQL 条件片段。 - 隔离级别和并发用例是上线门槛:确认数据库实际隔离级别,并完成 §10 的冲突、非冲突、锁等待和死锁用例后,才能启用跨表 guards。
- guards 只对本次实现的唯一定位路径生效:
delete_target/ 依赖删除这类旁路拿不到 guards,携带即拒绝(§4.1),绝不能让它静默失效。 - 整行 version 不作为普通编辑默认机制:普通更新只提交变化字段;需要检测同字段覆盖时,用
$original声明字段原值,只有整单动作才在$original里声明b_row_version(§3.1)。
3. 请求格式
3.1 表块新增字段
在现有表块(table / key_field / inserts / updates / deletes)之上新增:
| 字段 | 类型 | 说明 |
|---|---|---|
guards |
对象 | 前置条件,按动作分开:inserts / updates / deletes 各一份 |
guards.<动作>[].condition |
string | SQL 片段。{列名} 是受控占位符;insert 从当前请求行取值并绑定为 ?,update / delete 解析为目标表别名下的列引用 |
guards.<动作>[].refs |
string[] | 声明的引用表(见 §3.4) |
guards.<动作>[].message |
string | 条件不成立时给用户的话。由写条件的人负责描述 |
guards.<动作>[].retryable |
bool | 默认 true。为 false 时文案不追加「请刷新后重试」(§5.2) |
updates 的行可以带可选的 $original 对象,声明「我这行是基于哪些旧值改的」,用于冲突检测:
{
"b_id": "D001",
"b_amount": 220.00,
"$original": { "b_amount": 200.00 }
}
服务端将其生成:
UPDATE t
SET [b_amount] = ?
FROM dbo.demo_order AS t
WHERE t.[b_id] = ?
AND t.[b_amount] = ?;
规则:
$original只比较调用方明确声明的字段;不传$original表示该字段接受普通局部更新, 不因为同一行其他字段变化而失败。$original不是 guard,失败统一返回并发冲突码2003(§5.1),不使用 guard 的业务文案。$original是保留元字段,不会进入SET;原值为null时生成IS NULL,不能使用= NULL。$前缀 = 保留元字段。本期只有两个:$original(WHERE 侧的原值)与$expr(SET 侧的表达式,见下)。 业务列一律b_前缀,不可能与元字段撞名。- 为什么叫
$original而不是$expected:这里放的是改动前的值。 「期望值」在中文语境里会被读成「希望变成的值」——而那恰恰是b_amount: 220.00本身,方向正好相反。 元字段平时没人细看,等排查并发问题时被名字带偏,代价很高。
整行版本锁也写在 $original 里。整单策略若启用版本锁,同样是往 $original 放,只是键是版本列:
{
"b_id": "D001",
"b_status": "审核通过",
"$original": { "b_row_version": 7 }
}
服务端将其转换为 WHERE t.[b_row_version] = ?,参数为 7,成功后执行
SET [b_row_version] = t.[b_row_version] + 1。两种写法共用同一个元字段,区别在于键:
$original 的内容 |
语义 | 冲突粒度 |
|---|---|---|
业务字段 { "b_amount": 200.00 } |
字段原值比较 | 字段级,不同字段可并行修改 |
版本列 { "b_row_version": 7 } |
整行版本锁 | 整行,任一场变更都冲突 |
两者可以写在同一个 $original 里(WHERE 条件 AND 在一起)。版本列由调用方在整单动作中显式带上——
后端不维护「哪些表是受保护表」的策略表,所以也不存在绕过策略的问题;
普通编辑不要加它,否则会把无关字段的修改也判成冲突(§7.3)。
为什么按动作分开:三种动作的前置条件通常不同——
| 动作 | 典型前置条件 |
|---|---|
| inserts | 主表未审核,才能新增明细 |
| updates | 只有草拟状态才能改;已核销的不能改 |
| deletes | 主表未审核、且该明细未核销,才能删 |
共用一个 guards 会让条件串味。而且三种动作的失败文案本来就不一样
("不能新增费用" / "不能修改费用" / "不能删除费用")——
所以即便是同一条条件,分开写也是对的。
同一动作内多行怎么办:guards 定义的是条件模板,{列名} 让它按行实例化。
批量新增时每行的条件可以不同({b_order_id} 取各自的值)——
所以不需要把 guards 下沉到行级。
占位符只识别完整的 {合法列名} token,不在 SQL 字符串字面量、注释或其他标识符内部做文本替换。
update / delete 生成 SQL 时固定使用目标表别名 t,例如 {b_order_id} 解析为 t.[b_order_id];
insert 解析为 ? 并按出现顺序加入参数。insert 行缺少被引用列时直接返回参数错误,不能静默当成空值。
占位符值为 null 时绑定 SQL NULL,条件需要匹配空值的调用方必须显式写 is null / is not null。
updates 的行内值新增表达式支持(用于账本字段,见 §8.5)。
表达式必须用显式包装对象,不能裸写字符串——否则服务端无法区分
「这是一个表达式」和「用户真的输入了 b_fee_count + 1 这段文字」:
{ "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } }
裸字符串一律按普通值参数化处理(现有行为不变)。表达式内容限定为:一个本表可写列,
可选的 + / -,以及一个带符号的整数或小数常量;不允许函数、子查询、其他表列、多个运算符或赋值。
服务端先解析成结构化表达式,再生成 SET [列] = t.[列] + ? 形式的 SQL,并校验列存在、不可写列和数值类型。
客户端不能通过请求指定锁提示(UPDLOCK / HOLDLOCK)或加锁顺序。锁完全由数据库按语句本身获取,
请求里没有任何锁相关参数。
3.2 示例:只有新增
[
{
"table": "demo_order_fee",
"key_field": "b_id",
"guards": {
"inserts": [
{
"condition": "not exists (select 1 from dbo.demo_order p where p.b_id = {b_order_id} and p.b_status = N'审核通过')",
"refs": ["demo_order"],
"message": "该单据已审核通过,不能新增费用"
}
]
},
"inserts": [
{ "b_id": "F010", "b_order_id": "D001", "b_fee_name": "停车费", "b_amount": 30.00 }
]
}
]
生成的 SQL:
INSERT INTO demo_order_fee (b_id, b_order_id, b_fee_name, b_amount)
SELECT ?, ?, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM dbo.demo_order p
WHERE p.b_id = ? AND p.b_status = N'审核通过');
参数按出现顺序绑定为 F010、D001、停车费、30.00、D001。
实际实现不得把这些值拼成 SQL 字面量;guard 中业务常量(如 N'审核通过')仍由调用方写在条件片段中。
3.3 示例:三种动作同在,条件各不相同
[
{
"table": "demo_order_fee",
"key_field": "b_id",
"guards": {
"inserts": [
{
"condition": "not exists (select 1 from dbo.demo_order p where p.b_id = {b_order_id} and p.b_status = N'审核通过')",
"refs": ["demo_order"],
"message": "该单据已审核通过,不能新增费用"
}
],
"updates": [
{
"condition": "not exists (select 1 from dbo.demo_order p where p.b_id = {b_order_id} and p.b_status = N'审核通过')",
"refs": ["demo_order"],
"message": "该单据已审核通过,不能修改费用"
},
{
"condition": "isnull(b_writeoff,'0') <> '1'",
"message": "该费用已核销,不能修改"
}
],
"deletes": [
{
"condition": "not exists (select 1 from dbo.demo_order p where p.b_id = {b_order_id} and p.b_status = N'审核通过')",
"refs": ["demo_order"],
"message": "该单据已审核通过,不能删除费用"
},
{
"condition": "isnull(b_writeoff,'0') <> '1'",
"message": "该费用已核销,不能删除"
}
]
},
"inserts": [ { "b_id": "F010", "b_order_id": "D001", "b_fee_name": "停车费", "b_amount": 30.00 } ],
"updates": [ { "b_id": "F001", "b_fee_name": "出租车费", "b_amount": 220.00 } ],
"deletes": [ { "b_id": "F002" } ]
}
]
注意「主表未审核」这条故意写了三遍——因为三种动作的失败文案不同。 条件可以相同,文案必须分开。
3.4 refs 的定位
它是给人看的依赖标注,不是给机器判的约束。
我们特意不禁止 guard 用子查询(跨表条件在「引用的行会被争抢」时可以正确判断状态,
但 guards 保证的是「写入那一刻条件成立」,不是「整个事务期间状态不变」——见 §11)。
代价是「这个条件依赖哪张表」埋在了 SQL 字符串里——而依赖谁决定了失效点,失效点决定并发是否安全。
refs 把它挪到明处,评审时可以一眼核对。
服务端能校验的很少:
| 能力 | 能否做到 |
|---|---|
| 声明的表存在 | ✓ |
| 声明完整(有没有漏写) | ✗ 条件是一段自由 SQL,看不出来 |
| 声明真实(有没有乱写) | ✗ 同上 |
最小可用规则:condition 里出现 from / join → refs 必填非空,否则拒绝。
这条保证「跨表条件一定有声明」。
若要升级成硬校验,两条路:
- 粗:从
condition文本里抓表名(from xxx/join xxx/dbo.xxx)比对。便宜,够挡漏声明 - 准:
SET SHOWPLAN_XML ON让数据库只解析不执行,从执行计划 XML 里取真实引用对象再比对。准确,但要解析 XML、单独走会话
取舍建议:「出现
from/join就必填refs」这条弱规则本次先不做—— 字符串字面量里出现from会误伤,也拦不住乱写,性价比低于它带来的噪音。refs先只当给人看的注释,靠评审时按上面这张表核对; 真要机器校验就直接上SHOWPLAN_XML,不要停在半吊子。
refs不提供任何并发保证。 声明正确的条件照样可能在并发下漏 (例如数别人的行)。并发安全仍然只靠 §7 的三条判据 + 并发用例。
3.5 兼容性
不传 guards 时,生成的 SQL 与现在完全一致。现有调用方不需要改动。
4. 服务端行为
4.1 SQL 拼装
| 动作 | 无 guards(现状) | 有 guards |
|---|---|---|
| update | UPDATE t SET ... WHERE pk = ? |
UPDATE t SET ... WHERE pk = ? AND (guards.updates 各条) |
| delete | DELETE t FROM table AS t WHERE <行内列全等> |
DELETE t FROM table AS t WHERE <完整唯一键条件> AND (guards.deletes 各条) |
| insert | INSERT INTO t (...) VALUES (...) |
INSERT INTO t (...) SELECT ?, ... WHERE (guards.inserts 各条) |
每个动作只拼自己那份 guards,互不影响。
五条硬性规则:
-
条件一律加括号——
AND优先级高于OR,不加括号会改变语义 -
{列名}= 当前行的这一列。服务端按动作决定怎么落地:动作 落地方式 原因 insert 取请求里该行的值,绑定为 SELECT ?参数行还不存在,没有列可引用 update / delete 直接写成目标别名 t下的列引用(t.[列])行已存在, WHERE本就能引用被更新的行因此 update / delete 的条件可以引用请求行里没带的列, 而 insert 的条件只能引用请求行里有的列。
-
insert 用
INSERT ... SELECT ... WHERE(T-SQL 允许无 FROM 的 SELECT) -
执行顺序保持不变(updates → 依赖删除 → deletes → inserts)—— 因此"哪个动作先失败"是确定的,错误提示也是确定的
-
guards.deletes只走唯一定位的DbUtils删除路径。saveTable里删除有三条路: 无旁路声明时走DbUtils.delete(guards 生效);delete_target非空走DataDeleteService.deleteWithinTransaction、 有依赖声明走DataDeleteService.deleteDependents——这两条拿不到 guards。 因此带guards.deletes时禁止这两条旁路,直接报参数错误,绝不能让它静默失效。 删除行还必须只携带完整key_field(复合键要齐全),批量条件删除带 guard 没有合理语义 (部分行满足时该删哪些)且 0 行时无法归因。典型用法是删子表明细时校验主表状态(主表已审核就不许删明细), 再叠加"该费用已生成发票就不许删"这类引用检查。 依赖删除的语义是「删本表时连带删引用它的行」,方向是父 → 子; 删子表不会触发它,所以这个场景与旁路天然不冲突。
所有 guards 结构、删除旁路互斥关系、唯一定位约束,都必须在执行 updates / deletes / inserts 之前完成校验; 参数错误不能出现"前几行已写入、随后才拒绝"的现象。
4.2 失败判定
沿用现有的 affectedRows != 1 检查(DbUtils.executeWrite 已有)。
带 guards 的 delete 已限定为"删除行只含完整唯一键"的逐行删除,因此同样要求影响行数为 1;
普通 delete(无 guards)继续保留现有的批量条件语义,不改变兼容性。
| 影响行数 | 含义 | 处理 |
|---|---|---|
| 1 | 正常 | 继续 |
| 0 | $original 不匹配、guard 不成立,或目标行不存在 |
按 §4.3 顺序区分原因 |
| > 1 | 条件写宽了(或 key_field 不是唯一键) | 系统异常,不是业务提示 |
4.3 0 行时逐个回查,定位是哪条不成立
0 行(某动作的第 i 行)按以下顺序处理:
update / delete:先 `select 1 from <table> where <key>` 判断目标行是否存在
不存在 → 普通保存失败(2001),不生成 guard 文案
update 带 `$original` 且原值不匹配 → 并发冲突(2003),不生成 guard 文案
存在且 original 通过 → 再逐条执行 `select 1 from <table> where <key> and (<guard.condition>)`
insert:`select 1 where (<guard.condition>)`,不带 <key>,见下
第一个明确返回空的 guard → 用它的 message
insert 的回查不能带 <key>:insert 失败时那行根本不存在,
where <key> 必然为空,结果永远落在第一条 guard 上,定位就废了。
insert 的行还不存在,只能对条件本身求值(占位符已替换为该行的请求值):
SELECT 1 WHERE (NOT EXISTS (SELECT 1 FROM dbo.demo_order p
WHERE p.b_id = ? AND p.b_status = N'审核通过'));
参数 ? 绑定当前 insert 行的 b_order_id,与实际写入语句使用同一类型。
- 只回查当前失败动作的那一份(
guards.updates/guards.deletes/guards.inserts),不是三份全查 - 只在失败路径执行,成本可忽略
- 走同一个连接(在抛异常之前),看到的是本事务视图
- 顺序即优先级
- 目标行存在但全部 guard 回查成立仍为 0 行(并发窗口)→ 用通用话术,不伪造某条 guard
- 结论是 best-effort(回查期间数据可能又变),仅用于文案
SaveGuardException 携带的 action 用于区分是哪一种动作失败——
前端可以据此给出更贴合的提示("改不动" vs "删不掉")。
4.4 异常与回滚
抛专门的 SaveGuardException(新增),携带 table / action / index / guard / refs。
DbUtils 负责报告 guard 及影响行数,DataSaveService.executeRows 在捕获后补充当前行的 index、
并保持原始异常作为 cause;不能依赖 DbUtils 自己推断批次行号。
为什么不用 SQLException:guard 失败是可识别的业务竞态,不应被包装成普通数据库错误。
executeRows 必须显式 catch SaveGuardException 并补充行号;其他 RuntimeException 才原样穿过 → 到 DataSaveService.save 的
catch (SQLException | RuntimeException) → 回滚 → 重抛 → 由全局处理器转成干净文案。
事务框架本身不需要改,但异常包装和全局 handler 必须新增测试。
回滚范围:整个 save() 是一个事务,任一行失败全部回滚。
文案里必须体现这一点(见 §5.2)。
5. 错误码与文案
5.1 错误码
现有常量:
public static final int SUCCESS_CODE = 0;
public static final int SAVE_ERROR_CODE = 2001;
public static final int BUSINESS_ERROR_CODE = 1000;
新增 GUARD_ERROR_CODE = 2002,用于 guard 不成立。
前端据此与普通保存失败区分(§6.3:展示后重新拉取当前状态)。
新增 CONFLICT_ERROR_CODE = 2003,用于 $original 里的字段原值或 b_row_version 不匹配;
它表示并发编辑冲突,不使用某条 guard 的业务文案。
是否提示「请刷新后重试」不能一律挂,由 guard 的 retryable 决定(§5.2)。
5.2 文案规范
好的失败提示 = 发生了什么 + 你该做什么。服务端拼装规则:
<message>,本次操作未生效。<retryable 为真时追加「请刷新后重试。」>
<message>:guards.<动作>[].message;未提供时用通用话术「该数据已被更新或不满足操作条件」- 「本次操作未生效」固定不变:整个请求是一个事务,用户会以为「存了一半」,这句不能省
- 「请刷新后重试」不固定,由
retryable(默认true)决定—— 两类原因的后续动作不同,写死一句就会误导:
| 原因类型 | 完整文案 | 刷新有用吗 |
|---|---|---|
状态已变(retryable: true) |
该单据已被处理或状态已变更,本次操作未生效,请刷新后重试。 | ✓ |
终态条件(retryable: false) |
该费用已核销,本次操作未生效。 | ✗ 不要让用户去刷 |
「已核销」「没有任何费用明细」这类刷新也改变不了的条件,写 guard 时务必置 retryable: false。
5.3 日志
- WARN 级别,不打堆栈——这是正常业务竞态,不是系统故障,用 ERROR 会刷满假告警
condition原文、refs、table/action/index全部进日志与details- 不进用户可见文案——
b_state = '送审'对业务人员没有意义
5.4 现状对照
当前 0 行会一路包成:
权限保存失败(改动未落库):保存失败: 修改记录数异常: 0
三层前缀,最后落在一句数据库计数上。改造后应为:
该单据已被处理或状态已变更,本次操作未生效,请刷新后重试。
6. 前端约定
- 条件生成收敛到一个函数(如
buildActionRequest(action, row)),不要散落在各页面自己拼。 guards 是 opt-in —— 漏传就是不保护,没有服务端策略兜底,收敛到一处才能避免遗漏。 - 收到 2002 → 直接展示
message,不要再加前缀。 现在的写法是'XX失败:' + error.message,会导致双重前缀。 - 收到 2003 → 按并发冲突处理:重新拉取当前行并标记冲突字段,不自动覆盖用户输入,也不自动重试。
- 展示 2002 后自动重新拉取当前状态。 用户的本能是再点一次;不刷新他会反复失败。
- 按钮可用性仍由前端预判,但它只是「建议」——最终以服务端 guard 为准。 算错了只是显示不准,点下去会被拦。
- 不要自动重试。自动重试会把判定再跑一遍,可能又成功,而用户不知情。
- guards 全程 opt-in,没有后端策略兜底——不传就是不保护,服务端不会替调用方补条件。
前端必须收敛到
buildActionRequest(action, row),避免状态提示和$original散落。 - 需要防同字段覆盖时提交
$original,只对变化字段提供旧值;不要为了防止一个字段冲突而提交整行快照。 整单动作才在$original里带b_row_version,普通编辑不要加(§3.1)。 http.js做不到「去掉前缀」。业务错误走的是成功拦截器(HTTP 200 +code != 0), 那里只抛 Error;前缀是 40+ 个调用点自己拼的(Message.error('保存失败:' + ...))。http.js能做的只是对 2002 打toasted并弹完整文案——但只有检查error.toasted的调用点(views/approval/*、部分 workflow 页面)不会被双弹。 改造要按调用点收敛,不要指望一刀切。
7. 并发安全的判据(核心)
条件本身不保证并发安全。对于只要求"执行时状态合法"的普通 guard,必须同时满足:
- 有行承载——判据是存下来的,不是现算的;
- 会被争抢——在"使条件变假"的操作里,那一行会被加写锁;
- 判定与写入在同一条语句里。
满足这三条,guard 就能保证「写入那一刻条件成立」。 它不保证整个事务期间状态不变——那是单据级串行,本设计不做(§11)。
7.1 对照表
| 判据形式 | 有行承载 | 会被争抢 | 仅靠 guard |
|---|---|---|---|
本表栏位 b_status |
✓ | ✓ | ✓ |
| 别的表栏位,且那行会被改(如主表状态) | ✓ | ✓ | ✓(写入那一刻状态已变则拒绝) |
| 别的表栏位,但那行没人改 | ✓ | ✗ | ✗ 需补业务锁点 |
count(*) / sum() |
✗ | ✗ | ✗ 先物化到一行 |
| JOIN / 视图 / 现算的公式 | ✗ | ✗ | ✗ 转成物化字段或专用服务 |
| 唯一索引 | — | — | ✓(另一条路) |
注意:跨表本身不是问题。「子表新增时校验主表状态」是常见用法,guard 足够; 它拦的是「状态已变更后仍写入」,不提供「审批与明细严格串行」的顺序保证(§11)。
7.2 冲突的真实表现
SQL Server 下的条件写是靠锁实现的 CAS,不是无锁的乐观并发。冲突时:
第二个请求阻塞等待第一个提交 → 等到了才发现不匹配 → 失败
即「转一会儿圈,然后报错」,不是「立刻同时失败」。前端提示要能配合这个体验。
跨表 guard 和多表保存还可能产生相反的加锁顺序:请求 A 先锁子表再锁主表, 请求 B 先锁主表再锁子表,就会出现死锁(1205),这不能当作 guard 不成立。 服务端应按固定的表名顺序获取写锁;无法统一排序时,捕获 1205 后回滚并在服务端有限重试一次, 前端不做盲目重试。锁等待超时(1222)返回独立的"系统繁忙,请稍后重试"错误,不返回 2002。
7.3 普通字段不要默认整行 version
整行 b_row_version 会把无关字段的修改也视为冲突:
甲修改 b_amount,版本 5 -> 6
乙修改 b_remark,仍带版本 5 -> 被拒绝
对于普通资料字段,saveobjt 已经是按提交字段生成 SET,应保留这种局部更新语义。
只有需要防止同字段覆盖时才传 $original,把字段旧值加入 WHERE;
金额、状态、明细账本等业务关键字段则由 guard 或专用服务保护。
整行 version 适合整单提交、批量导入、不可拆分的状态迁移,不适合作为所有普通编辑的默认锁。
8. 不能处理的情况与对策
8.1 判据分类
| 类型 | 例子 | 处理 |
|---|---|---|
| 状态类(单行) | b_status = '送审' |
guard |
| 跨表但会被抢 | 主表状态 | guard(子查询与写入同一条语句) |
| 聚合类 | 会签人数、核销金额、库存 | 物化(§8.2) |
| 同字段并发编辑 | 两人同时改 b_amount |
$original 字段原值比较或专用服务 |
| 无关字段并发编辑 | 一人改金额、一人改备注 | 局部更新,允许并行 |
| 权限类 | 是不是待审人 | 权限 / 数据范围,不放进 guard |
| 复杂综合规则 | 无定式 | 悲观锁 + 服务端方法 |
8.2 物化:把「数出来的」变成「记下来的」
判据:这个约束的真假,能不能由一行的一个字段表达?不能就物化。
| 约束 | 现算(会漏) | 物化(安全) |
|---|---|---|
| 所有明细都核销才能关闭 | not exists(明细 where 未核销) |
表头.b_all_writeoff = 1 |
| 明细条数 > 0 | count(*) > 0 |
表头.b_fee_count > 0 |
| 表头金额 = 明细合计 | sum(明细) |
公式字段(FMS 已有) |
| 会签人数 | count(已通过) |
wf_node_run.b_completed_count |
| 预算 / 额度 | sum(已用) <= 预算 |
额度行.b_used + 本次 <= b_total |
| 一人一节点只批一次 | not exists(...) |
唯一索引 |
「虚拟」不是问题,「没存」才是。 算完存回栏位即可用——FMS 的计算公式正是这么做的。
8.3 聚合根
物化要落到某一行上。找不到天然聚合根时,说明数据模型缺一张表——加它:
create table dbo.b_expense_budget (
b_company varchar(50) not null,
b_year int not null,
b_total decimal(18,2) not null,
b_used decimal(18,2) not null default 0,
primary key (b_company, b_year)
);
关键动作是加一行,不是加一个查询。
8.4 入口收敛
| 做法 | 强度 | 问题 |
|---|---|---|
| 调用方在同请求里一起写 | 弱 | 绕过此入口的写入(导入、其他模块、手工 SQL)会让账本漂移 |
| 服务端统一入口拦截 | 强 | 需动服务端;可统一注入账本更新等副作用 |
| 数据库触发器 | 最强 | 本项目明确不建触发器(审计列由写入方维护) |
推荐第二种。项目内已有后端拦截先例(公式保存,FormulaConfigService):
if (!isSystemTable(tableName)) {
List<ModuleConfig> byTable = findFormulaConfigsBySaveTable(connection, tableName);
if (!byTable.isEmpty()) {
throw new BusinessException(
"表 " + tableName + " 属于带计算公式的模块("
+ byTable.get(0).moduleId()
+ "),保存必须携带 moduleCode 并由公式保存处理器校验"
);
}
}
即:这张表有约束 → 绕过统一入口就拒绝。
客户端只提交业务变更和可选 $original,不提交任何锁或策略参数——
表里有没有 b_row_version、要不要用它,都是调用方按动作自己决定的事。
8.5 账本字段需要表达式值
账本若只能用 CAS 写法(guard: b_fee_count = 2 + set: 3),
两人同时给同一单加费用会产生假冲突——两笔费用互不影响,却有一方失败。
表达式值可以消除(必须用 { "$expr": ... } 显式包装,见 §3.1——
裸字符串按普通值参数化,会变成 SET b_fee_count = 'b_fee_count + 1' 直接报错):
{ "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } }
-- → UPDATE t SET [b_fee_count] = t.[b_fee_count] + ? FROM demo_order AS t(参数为 1)
额度类还需要「条件 + 增量同句」:
UPDATE b_expense_budget SET b_used = b_used + ?
WHERE b_company = ? AND b_year = ? AND b_used + ? <= b_total
表达式必须在公式重算之后、审计列补充之前解析;表达式只能引用当前保存表,不能跨表或引用 guard。 表达式参数与普通值参数统一使用 PreparedStatement 绑定,不能把常量拼入 SQL。
这是物化方案的必要配套,不是可选项。
8.6 冗余必须能对账
物化 = 冗余 = 可能漂移。这是代价,要认,然后用对账兜住:
select o.b_id, o.b_fee_count, count(f.b_id) as real_count
from dbo.demo_order o
left join dbo.demo_order_fee f on f.b_order_id = o.b_id
group by o.b_id, o.b_fee_count
having o.b_fee_count <> count(f.b_id);
做成运维页面或定时任务。账本能被信任的前提是它能被核对。
8.7 实在物化不了:悲观锁
select ... from dbo.demo_order with (UPDLOCK, HOLDLOCK) where b_id = ?
-- 读 → 判 → 写,同一事务
局限:只对「经过这段代码」的路径有效。手工 SQL、其他模块、导入照样绕过。 它是应用层约定,不是数据层保证。
9. 改动清单
| 文件 | 改动 | 说明 |
|---|---|---|
utils/DbUtils.java |
update / delete 加带 guards 的重载 |
拼 WHERE,条件加括号 |
utils/DbUtils.java |
insert 支持参数化的 INSERT ... SELECT ... WHERE |
guard 占位符和值都走参数绑定 |
utils/DbUtils.java |
executeDelete 补行数校验 |
带 guards 时必须恰好影响 1 行;无 guards 保持批量删除兼容 |
utils/DbUtils.java |
update 的 SET 支持结构化表达式值 | 账本自增(§8.5),解析后参数化 |
service/data/DataSaveService.java |
saveTable 按动作解析 guards.inserts / guards.updates / guards.deletes,分别透传给 executeRows |
保持现有执行顺序 |
service/data/DataSaveService.java |
0 行时先判断目标行存在,再逐个回查该动作的 guards | 不把"主键不存在"误报成业务 guard |
service/data/DataSaveService.java |
delete_target / dependent_deletes 与 guards.deletes 互斥 |
本次拒绝旁路静默绕过 |
service/data/DataSaveService.java |
guard token、动作结构、占位符列和唯一定位规则校验 | 参数错误在写入前失败 |
exception/SaveGuardException.java |
新增 | 携带 table / action / index / guard / refs |
exception/GlobalExceptionHandler.java |
新增 handler | 返回 2002 + 干净文案;日志 WARN 不打堆栈 |
utils/ApiResponse.java |
新增 GUARD_ERROR_CODE = 2002、CONFLICT_ERROR_CODE = 2003 |
guard 失败与字段 / 版本冲突分开 |
utils/DbUtils.java |
guard 占位符值统一绑定 PreparedStatement 参数 | 不拼 SQL 字面量,避免类型和 NULL 语义错误 |
utils/DbUtils.java |
update 支持 $original 原值比较(含 b_row_version 整行模式) |
只比较声明的字段,不把无关字段误判为冲突;版本列要求表上存在可写的 b_row_version |
service/data/DataDeleteService.java |
不改动 | 带 guards 的删除禁止走 delete_target / 依赖删除旁路;删除服务本身不接收 guards |
service/data/DataSaveService.java |
0 行回查时 insert 不带 key | §4.3 |
fms-vue/src/services/http.js |
2002 打 toasted + 弹完整文案 |
只是去重,不能替调用点去掉前缀;真正要改的是那些拼前缀的调用点(§6.7) |
| 前端审批类动作 | 抽 buildActionRequest(action, row) |
条件与文案集中生成 |
| 测试 | 新增 DataSaveServiceGuardTests |
§10 的 10 类用例:并发 / 0 行 / 兼容性 / 多表回滚 / 表达式值 / 删除语义 / 数据库锁行为 / 跨表时序 / 字段冲突 / 局部更新 |
10. 测试要求
- 并发用例(必须)——按业务不变量设计冲突和非冲突两类用例,不能统一断言"只有一个成功": 同一状态迁移应一成一败;两个独立明细新增可以都成功;跨表 guard 要验证实际锁顺序和最终状态。
- 0 行用例——主键不存在(返回 2001 且不带 guard 文案)、guard 不成立、多个 guard 中第 2 条不成立(验证回查定位准确)
- 兼容性用例——不传 guards 的现有请求,SQL 与行为不变
- 多表事务——第 2 个表块失败时,第 1 个表块的写入确实被回滚
- 表达式值——
b_fee_count + 1并发两次,结果应为 +2(无假冲突) - 删除语义——带 guards 的删除只能命中唯一键;批量条件、
delete_target、依赖删除均应在写入前拒绝。 - 数据库行为——在实际隔离级别下验证锁等待、1205 死锁、1222 超时的回滚和错误码,不把它们误判为 2002。
- 跨表 guard 的时序——明细新增 vs 审批并发,断言先提交者成功、后提交者被 guard 拦下,
且返回的是
2002(不是2001);不要求两者按固定顺序串行。 - 字段冲突——两人改不同字段时都成功;两人改同一字段时,带
$original的请求一成一败并返回 2003;$original含b_row_version时退化为整行语义,任一场变更都冲突。 - 局部更新兼容性——不带
$original的不同字段更新不互相覆盖;公式、审计列和账本字段仍按服务端规则处理。
11. 边界与不做
- 不禁止 guard 使用子查询。跨表条件在「引用的行会被争抢」时可以正确判断状态;
静态无法判定的部分靠
refs声明 + 并发用例覆盖 - 不做行级 guards。
guards到「表块 × 动作」这一层为止; 同一动作内多行的差异由{列名}占位符实例化表达(见 §3.1) - 不做单据级串行(原 §4.0 保存策略 / 聚合根锁,已移除)。guards 保证的是 「写入那一刻条件成立」——判定与写入压在同一条语句里,因此不存在「先读后写」的窗口; 它不保证整个事务期间状态不变,也不保证两个事务按某个固定顺序完成。 需要严格提交顺序的场景走专用服务(§8.7),不在通用保存里解决。
- 快照隔离下 insert 的 guard 可能读到旧值。
INSERT ... SELECT ... WHERE (子查询)的子查询 在 RCSI / SNAPSHOT 下读语句快照、不加锁,可能读到已被别人改写的旧状态。UPDATE不受影响(写操作会重读最新提交版本)。这是 §12 第 1 条要求先确认隔离级别的原因。 - 不改现有调用方。不传 guards 行为不变
- 不建触发器,遵循现有约定
- 不把整行乐观锁作为普通编辑默认机制——整单提交、批量导入和明确要求整单一致的动作可以启用
b_row_version(同样是写在$original里);普通编辑使用局部更新或字段级$original。 - 本次不做请求幂等(§1.3)——后续按 workflow 的
b_request_id方案补入 saveobjt - 不把
wf_task/wf_cc登记成数据模块——现有设计有意如此,避免给通用保存开后门 - 核销状态暂不纳入本机制。核销是「按金额、可部分、多行」的模型, 与审核状态的粒度不同,应走专用接口(旧系统即如此)
12. 未决问题
| # | 问题 | 状态 | 说明 |
|---|---|---|---|
| 1 | 数据库隔离级别 | 前置条件,动手前必须确认 | 代码未设置。RCSI 下 UPDATE ... WHERE <谓词> 的谓词是在语句快照上求值、还是加锁后重算,与 SNAPSHOT(抛 3960 更新冲突)行为不同——这决定本方案的 CAS 前提是否成立。先 DBCC USEROPTIONS 确认,再用 §10 第 1 条的并发用例实测 |
| 2 | 死锁 | 实现约束 | 多表请求 + guard 子查询按固定表名顺序获取锁;仍发生 1205 时回滚并由服务端有限重试一次,不向前端自动重试 |
| 3 | 锁等待超时 | 实现约束 | save 连接设置 3~5 秒 LOCK_TIMEOUT,1222 返回独立的系统繁忙错误;连接归还连接池前恢复原设置,避免污染后续请求 |
| 4 | 多行动作中途失败时的回查失真 | 已定 | 回查只用于生成 best-effort 文案;以当前事务视图为准,若目标行存在但所有 guard 均成立,使用通用并发失败文案,不声称具体哪条条件失败 |
| 5 | refs 是否强制登记 |
已定:本次不做 | 弱规则(from / join 就必填)性价比低,见 §3.4 |
| 6 | 表达式值的语法边界 | 已定 | 用 { "$expr": "..." } 显式包装(§3.1 / §8.5);内容限定为「本表列 ± 字面量」 |
| 7 | $original 冲突码 |
已定 | 字段原值或 b_row_version 不匹配统一返回 CONFLICT_ERROR_CODE = 2003,不走 guard 文案 |
| 8 | 元字段命名 | 已定 | 原值统一用 $original;整行版本锁并入 $original 的 b_row_version 键,不再单列 $expected_row_version(§3.1) |
| 9 | 不做单据级串行 | 已定 | 已移除原 §4.0 保存策略 / 聚合根锁。guards 只保证「写入那一刻条件成立」,需要严格顺序的场景走专用服务(§11) |
(已定,不再讨论:guards 粒度 = 表块级,按动作分开;
不做成行级,因为同一动作内多行的差异由 {列名} 实例化即可表达。)
13. 推荐落地顺序
不要一次把所有并发能力铺到全部模块,建议按下面顺序逐步启用:
- 先落地 guards:确认数据库隔离级别(§12 第 1 条),加入按动作条件、2002 文案、 0 行准确归因和删除约束;普通表可继续兼容无 guards 请求。
- 加入
$original:只对确实需要防同字段覆盖的字段启用,返回 2003;整行b_row_version只用于整单动作,不作为普通编辑默认值。 - 加入账本表达式和对账任务:金额、数量、明细数使用数据库增量,定期核对物化字段与明细真实数据。
- 最后补整单 version 与 request_id:只用于整单提交、批量导入和可重试的后台动作。
每一步都必须保留同一模块的并发回归用例,确认新策略不会改变普通字段局部更新的兼容行为。
附录 A:Demo(可直接执行)
A.1 建表
/* 主表:报销单 */
create table dbo.demo_order (
b_id varchar(50) not null primary key,
b_no nvarchar(50) null, -- 单号
b_amount decimal(18,2) not null default 0, -- 累计金额
b_fee_count int not null default 0, -- 明细条数(账本字段)
b_status varchar(20) not null default N'草拟', -- 草拟/送审/审核通过/审核驳回
b_updated_by varchar(50) null,
b_updated_at datetime2 null
);
/* 子表:费用明细 */
create table dbo.demo_order_fee (
b_id varchar(50) not null primary key,
b_order_id varchar(50) not null, -- → demo_order.b_id
b_fee_name nvarchar(100) null,
b_amount decimal(18,2) not null default 0,
b_writeoff varchar(10) null, -- 核销标志:0/1(§3.3 示例用到)
b_updated_at datetime2 null -- 审计列由服务端补(§1.6)
);
A.2 造数据
insert into dbo.demo_order (b_id, b_no, b_amount, b_fee_count, b_status) values
('D001', N'BX-2026-001', 1200.00, 2, N'草拟'),
('D002', N'BX-2026-002', 800.00, 1, N'审核通过'), -- 已审核,用来演示拦截
('D003', N'BX-2026-003', 5000.00, 2, N'送审'), -- 用来演示并发
('D004', N'BX-2026-004', 0.00, 0, N'送审'); -- 无明细,用来演示多 guard
insert into dbo.demo_order_fee (b_id, b_order_id, b_fee_name, b_amount) values
('F001', 'D001', N'打车费', 200.00),
('F002', 'D001', N'餐费', 1000.00),
('F003', 'D002', N'住宿费', 800.00),
('F004', 'D003', N'机票', 4000.00),
('F005', 'D003', N'机场大巴', 1000.00);
A.3 四个场景
| 场景 | 请求要点 | 生成 SQL 的影响行数 | 结果 | 提示 |
|---|---|---|---|---|
| A | 给 D001(草拟)加费用 | 1 | 通过 | — |
| B | 给 D002(已审核)加费用 | 0 | 拦下 | 该单据已审核通过,不能新增费用 |
| C | 给 D003 加费用 vs 审批 D003 | — | 看谁先提交 | 审批先提交 → 加费用被 guard 拦下(2002);加费用先提交 → 审批按新状态处理 |
| D | 审批 D004(送审但无明细) | 0 | 拦下 | 该单据没有任何费用明细,不能通过审批 |
场景 A / B 用的是 guards.inserts,场景 D 用的是 guards.updates:
"guards": {
"updates": [
{ "condition": "b_status = N'送审'", "message": "单据状态已变更,请刷新后重试" },
{ "condition": "b_fee_count > 0", "message": "该单据没有任何费用明细,不能通过审批",
"retryable": false }
]
}
生成 SQL(两条 AND 在一起,仍然是一条语句):
UPDATE demo_order SET b_status = N'审核通过'
WHERE b_id = 'D004'
AND (b_status = N'送审')
AND (b_fee_count > 0);
b_fee_count = 0 → 0 行 → 回查:
SELECT 1 FROM demo_order WHERE b_id = 'D004' AND (b_status = N'送审'); -- 有行 ⇒ 通过
SELECT 1 FROM demo_order WHERE b_id = 'D004' AND (b_fee_count > 0); -- 空 ⇒ 就是它
→ 提示准确到第二条。若两条拼成一个大字符串,只能给「操作失败,请刷新」—— 而这条刷新也没用(它确实没明细)。
附录 B:核心结论
并发安全的前提是「有一个东西在被争抢」。
存下来的判据有行、能被锁,所以可以做 guard;判定与写入压进同一条语句,才不会留下读与写之间的窗口。 现算的判据没有行、锁不住,所以只能看不能判;数出来的东西没有归属——给它造一个归属,这就是物化。 普通字段则不必整行加锁:按字段局部更新,必要时用
$original检测同字段覆盖。