V8 接口引擎事务归属与回滚边界的封面图

架构解释图(确定性渲染)|一次返回码如何穿过事务归属、安全代理与嵌套写入

摘要| 一个接口引擎先更新库存表,再写流水表。校验没过,脚本返回失败,库存却已经落库。这不是回滚失效:那次更新压根没参加这次请求的事务。表单引擎和嵌套接口引擎在拿不到事务参数时,会各自开一个事务并各自提交;真正决定收场的,是事务归属和第三个位置参数 V8.DbTrans。

✦ 01|现象:外层返回失败,前面那次写入还在

一个接口引擎先更新库存表,再写入一条流水记录。库存更新返回成功,流水写入因为唯一键冲突返回非 1;脚本走到末尾,返回 Code 为 0 和一句错误提示。

调用方收到失败,回头再查库存,数量已经变了。返回码、日志、HTTP 状态都正常,看起来只有回滚没生效。可平台确实执行了回滚动作,只是它回收的范围,比写脚本的人以为的小得多。

  • 单看脚本,失败分支没有写错:非 1 就返回失败,逻辑清楚。
  • 单看平台,返回码也确实按失败处理:非 1 会触发一次回滚。
  • 对不上的是两者管的写入不是同一批:回滚发生在某一层事务里,而那次更新已经不在这一层。

现象定位| 失败是失败的,回滚也是回滚的,只是回滚覆盖不到那次已经自行提交的写入。把它当成“平台没回滚”,会一直往错误的方向找。

✦ 02|直觉方案为什么不够:Code 不是全局开关

第一反应通常是返回码语义没生效。但平台的判定很直接:返回 DosResult 或带 Code 的对象时,Code 等于 1 提交,其它值回滚;返回对象却没有 Code,同样按回滚处理。这几条写在官方服务端 V8 文档里,与当前实现一致。

第二反应是“一个接口引擎只有一个事务”。恰恰相反,嵌套调用可以各自拥有事务:表单引擎和嵌套的接口引擎在拿不到事务参数时,都会自己开一个,并在自己这一层提交。

  • 外层引擎的事务里,只有它自己发起的语句,没有表单引擎代它执行的那些。
  • 表单引擎自己开的事务在执行返回前就结束了,外层之后再回滚也回收不到。
  • 只有把事务对象顺着调用链传下去,这些写入才会落进同一个事务。

结论| Code 决定的是“持有事务的那一层”怎么收场,不是“这一次请求”怎么收场。事务有几个,收场就有几次;返回值只影响最外层那一个。

✦ 03|调用链:一次请求里事务到底归谁

接口引擎从 HTTP 进入平台后,先取租户主库会话,再决定事务。当前实现的写法很朴素:有传入事务就复用,没有就新开一个,执行结束后的清理再按“是否由本次调用拥有”决定释放。

引擎把事务包成安全代理再交给脚本,所以脚本里的 V8.DbTrans 不是真实事务对象。代理会拦下 Commit、Rollback、Close 三种调用,各记一条诊断日志,脚本无法自己提前收场。

从 HTTP 入口到嵌套写入的事务归属调用链

架构解释图(确定性渲染)|一次请求里的五段:入口、归属判定、安全代理、跨表写入、单次收场

易混点| 安全代理是一个可传递的入口,不是可管理的事务句柄。它能被当成第三个参数交出去,但不能被收场;收场只有一次机会,且只在拥有它的那一层。

✦ 04|源码事实:三处守卫决定怎么收场

提交和回滚被同一个条件挡住:本次调用不拥有事务时,既不提交也不回滚。这条判断在接口引擎里出现三次——Code 等于 1 的提交、Code 非 1 的回滚、以及抛出异常时的回滚,三处都带同一个归属检查。

  • 返回可识别的 DosResult 或带 Code 的对象:Code 等于 1 提交,其它值回滚。
  • 返回普通对象但没有 Code:按回滚处理,避免忘记带状态就误提交。
  • 返回字符串、数字、数组、布尔值或 null,且脚本没有抛异常:默认提交。
  • 显式传入了外层事务:提交与回滚都由外层调用者决定。

表单引擎的修改路径把这条规则写得更直白:函数入口先记一个“是否外部事务”,随后执行的 UPDATE 显式跑在同一个事务上,收尾时只有“不是外部事务”才提交或回滚。异常路径同样带这个前置条件。

四类返回值与事务归属组合后的收场结果

架构解释图(确定性渲染)|归属与返回码的四种组合,以及各自的真实收场

源码边界| 事务归属在函数入口就确定了:有外部事务就是外部事务,没有就自己开。后面所有判断都只是复用它,不会中途改变。

✦ 05|第三个位置参数:把跨表写入并进同一事务

修正并不复杂:把 V8.DbTrans 作为第三个位置参数,传给每一次表单引擎调用和嵌套的接口引擎调用。当前接口签名本身就支持这件事,更新、删除、按条件更新、批量方法与接口引擎调用都接受第三个事务参数。

// 有问题的写法:两次写入各自开事务、各自提交
var stock = V8.FormEngine.UptFormData('Shop_Stock', {
    Id: stockId, Qty: left
});
if (stock.Code !== 1) return { Code: 0, Msg: '库存扣减失败' };

var log = V8.FormEngine.AddFormData('Shop_StockLog', {
    StockId: stockId, Change: -1
});
if (log.Code !== 1) return { Code: 0, Msg: '流水写入失败' };

return { Code: 1 };
// 第二次失败时,第一次扣减已经自行提交,外层回滚带不走它
// 修正写法:两次写入都显式加入当前接口引擎的事务
var stock = V8.FormEngine.UptFormData('Shop_Stock', {
    Id: stockId, Qty: left
}, V8.DbTrans);
if (stock.Code !== 1) return { Code: 0, Msg: '库存扣减失败' };

var log = V8.FormEngine.AddFormData('Shop_StockLog', {
    StockId: stockId, Change: -1
}, V8.DbTrans);
if (log.Code !== 1) return { Code: 0, Msg: '流水写入失败' };

// 只有走到这里才提交;上面任何一次提前 return 都会带两次写入一起回滚
return { Code: 1, Data: { StockId: stockId } };

两段代码的差别不在长短,而在事务数量:前者是三个事务各自收场,后者是一个事务一次收场。事务参数一旦传下去,写入就只是排队,真正决定命运的只有最外层那一次返回。

最小改动| 不需要 try/catch,也不需要自己补回滚调用。要做的是把同一个事务对象传下去,并让每一条提前返回都带上非 1 的状态码。

✦ 06|聚焦验证:40 条源码断言与本地事务契约

文中的全部实现性描述都绑定到当前工作区源码的具体行号。我用一个探针脚本逐行读取实现文件,断言 40 处关键结构确实出现在断言行上,全部一致才返回 0;任何一行被改动,探针会直接指出期望值与实际值。

事务对象本身的行为另跑了一组本地聚焦测试:安全代理把提交后回调注册到框架持有的事务上、回滚时清空回调、重复提交真实事务会抛异常;V8 返回值的两种写法与优先级也在同一组用例里被断言。两条命令的原始输出都留在证据目录。

# 逐行断言文章引用的 40 处实现结构,退出码 0 才继续
python -X utf8 verify-tx-boundary.py --out evidence/tx-boundary-probe.json

    # 事务代理与 V8 返回码语义的聚焦测试
dotnet test Microi.Server/Microi.Tests/Microi.Tests.csproj -c Release \
  --filter "FullyQualifiedName~DbTransAfterCommitTests|FullyQualifiedName~V8ResultAssignmentTests"

本地聚焦验证的真实运行结果板

本地验证截图|源码断言探针与事务契约测试的真实输出指标

  • 源码事实:40 条断言在断言行上逐条命中,退出码 0。
  • 本地验证:事务契约与返回值语义的聚焦测试通过,退出码 0。
  • 未验证:真实数据库上跨表回滚的锁等待与提交时序,需要连库环境才能观测。

证据等级| 这组验证不连接数据库,证明的是事务归属规则与返回码语义本身,不是线上行为。把本地通过当成生产结论,是另一类误判。

✦ 07|失败与恢复边界:四种收场方式

把边界摊开,收场只有四种:整批提交、整批回滚、局部已提交、事务尚未收场。第四种最隐蔽,通常来自有提前返回却没带状态码的分支,事务悬在那里由外层清理决定命运。

  • 脚本抛出异常:走回滚分支,返回失败与堆栈信息。
  • 返回 Code 为非 1:回滚,并把该返回值原样交给调用方。
  • 返回带字段但没有 Code 的对象:按回滚处理,避免误提交。
  • 不返回任何值或返回标量:默认提交,即使中间某次写入已经失败。

表单后端事件还有一道额外门:只有调用方把 InvokeType 标成 Client,提交前与提交后事件才会执行。默认的 Server 调用不触发表单事件,写在事件里的 Code 校验在那种调用下等于不存在。

// 表单事件里的跨表写入同样要带 V8.DbTrans,失败必须返回非 1
var r = V8.FormEngine.UptFormData('Other_Table', {
    Id: V8.Form.Id, Locked: 1
}, V8.DbTrans);
if (r.Code !== 1) {
    return { Code: 0, Msg: '锁定失败,主表提交一并回滚' };
}
return { Code: 1 };

恢复边界| 想被回滚的写入必须带事务参数,想中止的提前返回必须带状态码。两件事缺任何一件,都会得到同一个结果:局部已提交,而且不报错。

✦ 08|生产取舍:默认提交是有意的

默认提交不是疏忽,而是兼容选择。平台上有大量只做查询或只做单次写入的脚本,如果强行要求每次返回都写 Code,历史代码会成片失败。于是规则变成:看得懂返回码就按返回码,看不懂就提交。

代价是安全性从“默认拒绝”变成“默认接受”。跨表写入、扣减与流水、主表与明细、状态机跳转这类场景,必须自己显式传递事务参数,并保证每一条提前返回都带状态码。

  • 同一业务动作涉及多张表时,统一使用第三个位置参数传入同一个 V8.DbTrans。
  • 把失败收口写成一个统一出口,禁止在中间分支裸 return 或只 return 一句提示语。
  • 需要表单后端事件参与校验的调用方,显式标注 Client 模式;不需要时不要打开它。

取舍| 平台把事务生命周期的所有权收在框架手里,换来脚本永远无法误提交;代价是每一次跨表写入都要自己声明加入哪个事务。Microi吾码AI 把这条规则放在官方服务端 V8 文档的第一个示例里,正是因为漏掉它是这类故障最常见的起点。

文中已标注的概念图由AI生成;源码与实测证据均来自当前工作区。

Logo

宁波官方开源宣传和活动阵地,欢迎各位和我们共建开源生态体系!

更多推荐