一次审批为什么会写出半条数据?用 Interlock 把状态变更、审计与 Outbox 锁进 PostgreSQL 事务
订单审批、账号冻结、内容发布这类动作,表面上只是把一列状态从 pending 改成 approved。但在真实服务里,它往往同时意味着:记录谁批准了、把版本号向前推进、写入审计历史、保存幂等结果,并向事务性 outbox 插入一条待投递事件。只要其中一项在独立 SQL 或独立网络调用里失败,系统就会留下“状态已变、事件没发”或“事件已排队、状态没提交”的半成品。
Interlock 是一个面向 TypeScript 与 PostgreSQL 的开源库,定位并不是替代 ORM 或工作流引擎,而是把单次领域状态迁移所需的一组数据库写入放进同一条 PostgreSQL 事务。项目以 Apache-2.0 发布;当前仍是 alpha 阶段,包通过 npm 的 next 标签分发,API 在 1.0 前可能调整。它的边界很明确:只保证事务内的记录一致,不负责执行长流程,也不替你把 outbox 事件投递到 Kafka、邮件服务或其他外部系统。
先明确问题:数据库事务之外的“成功”不可靠
很多项目最初会在一个 handler 中手动拼出流程:开始事务、抢占幂等键、按版本更新订单、插入审批记录、写入 outbox、提交事务。这样的代码并非必然错误,问题在于协议会被复制到每一个命令处理器中。少一次版本条件、漏一次 rollback,或把审计 INSERT 放到 COMMIT 之后,都会制造很难复现的并发缺陷。
Interlock 将这套协议收拢为生命周期(lifecycle)和资源绑定(binding)。生命周期声明允许的状态、事件、授权与事件载荷;绑定仍由应用自己编写,用来把框架的操作映射回既有表结构和 SQL。也就是说,它不会猜你的多租户模型、权限模型或订单字段;这些业务事实仍应由应用和数据库约束负责。
安装核心包、PostgreSQL 驱动以及应用自有的 pg 客户端:
npm install @jajego/interlock@next @jajego/interlock-postgres@next pg
官方说明中,两个包均为 ESM,参考驱动要求 Node.js 22.14+;pg 是 peer dependency。连接池属于应用,而不是由库在背后新建,这一点对连接数治理和测试隔离很重要。
把“批准”定义成一个可检查的状态迁移
下面的例子把订单限制在 pending → approved。defineEvent 产生带类型的事件构造器,authorize 把审批权限变成迁移的一部分,outbox 则描述要和状态更新一起写入的事件,而不是立刻向外部 broker 发消息。
import {
canonicalHash,
defineEvent,
defineLifecycle,
deny,
} from "@jajego/interlock";
type Order = {
id: string;
state: "pending" | "approved";
version: string;
};
type Actor = { id: string; tenantId: string; canApprove: boolean };
const event = defineEvent();
const orderLifecycle = defineLifecycle()({
name: "order",
states: ["pending", "approved"],
history: {
resourceType: "order",
actor: (actor) => ({ actorType: "user", actorId: actor.id }),
},
idempotency: {
fingerprint: ({ resourceId, event, actor, expectedVersion }) =>
canonicalHash({ resourceId, event, actorId: actor.id, expectedVersion }),
},
events: {
approve: event({
from: ["pending"],
to: "approved",
authorize: ({ actor }) =>
actor.canApprove ? true : deny({ code: "NOT_ALLOWED" }),
mutate: ({ actor }) => ({ approvedBy: actor.id }),
outbox: ({ resource, transitionId }) => [{
topic: "order.approved",
key: resource.id,
payload: { orderId: resource.id, transitionId },
}],
}),
},
});
这段定义并不等于“任何人都可批准”。相反,事件只允许从 pending 出发,授权函数在真正提交前还会再次检查。canonicalHash 把资源、事件、操作者和预期版本组合成幂等指纹:同一个幂等键如果被拿去执行不同命令,应返回冲突而不是悄悄复用旧结果。
真正的并发保护仍在条件 UPDATE
生命周期负责描述规则,资源绑定负责让规则落到表上。关键 SQL 仍是带有状态和版本条件的 compare-and-swap 更新:
UPDATE orders SET state = $2, version = $3, approved_by = $4 WHERE id = $1 AND state = $5 AND version = $6 RETURNING *;
如果另一个请求已先批准同一订单,这条语句不会“覆盖”对方的版本。绑定应把未命中的情况转成预期的冲突结果,而不是把它误报为服务器故障。多租户也不能只依赖一个连接级设置:加载订单时应把 tenantId 明确放进应用查询或由 RLS/触发器消费。Interlock 的基础设施表按 (lifecycle, resource_id) 识别资源,因此租户内可重复的 ID 必须由应用加命名空间,或直接改用全局唯一 ID。
创建客户端时,把应用已有的池和绑定传给 PostgreSQL 驱动:
import { createInterlock } from "@jajego/interlock";
import { PostgresDriver } from "@jajego/interlock-postgres";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orders = createInterlock({
lifecycle: orderLifecycle,
binding: orderBinding,
driver: new PostgresDriver(pool, { schema: "interlock" }),
});
首次部署还要执行包导出的 migration.sql,它创建幂等、迁移历史与 outbox 所需的表。迁移应在专用连接上完成;不要为此修改共享运行时连接池的 search_path。项目文档也提示:幂等迁移只支持 Read Committed,库会拒绝把带幂等键的命令放到更高隔离级别中,并不声称提供未经验证的并发算法。
assess() 是提示,transition() 才是提交
前端想在用户点击前展示“你无权审批”时,可以调用 assess();它是只读检查,不会预留任何资源,因此不能取代提交。服务端收到实际命令后仍应执行 transition(),让它重读权威状态、重做授权判断,再在一笔事务中完成写入:
const result = await orders.transition({
id: "order-123",
event: "approve",
actor: reviewer,
expectedVersion: "7",
idempotency: { key: "approve-order-123-request-42" },
});
switch (result.status) {
case "committed":
console.log(result.duplicate ? "already-applied" : "approved");
break;
case "denied":
case "conflict":
case "not-found":
case "idempotency-conflict":
case "invalid-input":
case "unknown-event":
console.log(result.status);
break;
}
committed 才表示完整事务已提交;相同幂等键的重放会返回已保存的迁移身份,而不是再次应用命令。denied、conflict、not-found 等是可预期的领域结果,应逐项映射为 API 响应或界面提示。连接在提交时中断则属于“提交结果未知”:这时不应盲目重试写操作,而应通过已持久化的幂等与历史数据进行对账。
Outbox 被原子写入,不等于消息已经送达
Interlock 在事务中插入 outbox 行,确保“订单已批准”与“存在待投递事件”同生共死;但真正的投递器、重试策略、死信队列和消费者幂等性仍在库的职责范围之外。这个取舍是健康的:数据库事务无法原子覆盖 PostgreSQL 与远端消息系统。实践中可由独立 worker 轮询或订阅 outbox 表,成功投递后再更新投递状态;消费者也应以事件 ID 去重。
还要避免把外部副作用塞进 mutate、授权函数或其他事务回调。它们可能因冲突、回滚或调用方重试而被重复执行。回调内部适合读取事务数据并构建写入计划,不适合直接扣第三方余额、调用支付接口或发送邮件。
什么时候值得引入,什么时候不必
当一条命令只修改一行、没有审计、没有异步后续动作,也没有并发竞争时,普通参数化 SQL 加数据库约束更直接。Interlock 的价值出现在“一个不可部分成功的业务变更”反复出现时:审批、订单推进、账号状态管理、内容发布、履约阶段切换等。
接入前先把三件事讲清楚:状态图是否足够小且明确;每个命令的幂等键由谁生成;outbox 的消费者如何去重与监控。随后从一个最容易出错的状态迁移开始,保留原有业务表与 SQL,只把版本检查、历史、幂等和 outbox 的事务边界统一起来。这样比把整个系统重写成“状态机平台”更可控,也更容易证明一致性改造真正解决了问题。
上线前要验证的不是“能跑”,而是失败路径
这类库最容易在 happy path 的演示中显得完美,风险却藏在失败分支里。建议为 pending → approved 至少建立四组集成测试:无权限操作者得到 denied;携带旧 expectedVersion 的并发请求得到 conflict;同一幂等键重复提交后返回原先的提交结果;在绑定的相关写入刻意抛错时,订单状态、历史和 outbox 都没有残留。最后一组尤其关键:它验证的是事务边界,而不是某条 SQL 恰好成功。
审计表也不是“写了就安全”。如果业务应用角色仍可对历史表执行 UPDATE 或 DELETE,append-only 只是代码约定,不是数据库事实。官方文档建议针对 interlock_transition_history 收紧这些权限;同时应把迁移记录、幂等冲突数、outbox 积压量和投递失败数纳入监控。库的 observer 可以报告操作开始、预期结果、重复重放、失败阶段和耗时,但 observer 属于尽力而为的遥测回调,不在事务中执行,不能替代持久化审计。
还有一个常见误解:一次 transition() 提交成功并不保证客户端立刻拿到响应。网络在 COMMIT 附近断开时,服务端可能已经提交而客户端却未知。此时以相同幂等键重试,并根据保存的幂等记录与迁移历史对账,才是可恢复的路径;简单地生成新键再执行一次,反而可能制造第二次批准或第二条外部动作。
过滤条件与一致性不是同一个问题
订单系统经常还要带上库存、额度、风控结论或审批人所属团队等关联事实。不要因为核心订单行做了版本检查,就假设这些事实天然稳定。绑定应明确在同一事务中读取哪些相关行、由哪一个版本或约束保护它们;如果多个 guard 需要同一份关系数据,可以在一次操作内缓存读取结果,避免重复查询,但不要把这类缓存扩展到跨请求的全局缓存。跨请求缓存会带来自己的失效语义,不能替代事务内的权威读取。
同样,expectedVersion 解决的是“我基于哪个订单版本作出决定”,不是通用的权限证明。一个用户在页面打开后可能失去审批角色,或订单的关联额度已被其他命令消耗;因此 transition() 再次执行授权和 guard 是必要的,而不是多余的性能负担。性能优化应优先从减少无用往返、复用温热连接池、批量写入相关行和缩小 outbox payload 入手,而不是跳过提交前复核。
对已有 ORM 项目也要诚实评估集成成本。Interlock 的一方 PostgreSQL 驱动使用原生 pg;ORM 可以在同一事务句柄中配合,但这需要适配层。特别是不能让 ORM 在自己的事务里写订单,再让 Interlock 在另一条连接上写历史与 outbox——这样恰好重新制造了它要消除的部分提交。先在一个命令上做端到端集成测试,确认所有写入确实共用同一事务句柄,再扩大覆盖面。