在几乎所有业务系统里,都能遇到一类看起来非常简单、做起来却异常折磨人的需求:订单只能从“待支付”变成“已支付”,绝不能从“已支付”变回“待支付”;退款单一旦进入“审批通过”,就不能再被用户撤销;任务的状态流转必须走固定路径,跳级会破坏整个流程。
这类规则如果只在代码里写 if/else,一开始确实没什么感觉。但等到并发上来、业务字段多起来、团队从一个人变成几个人,问题就会以各种隐蔽的方式冒出来:同一笔订单被两个接口同时处理,状态被后写入的一方覆盖;数据库里出现了一个代码里从未定义过的状态组合;TypeScript 类型定义说这个状态合法,线上数据却根本对不上。
这类问题有一个共同的名称:领域转换(domain transition)。而 Hacker News 上出现的 Show HN 项目 Interlock,标题非常直接——“Atomic TypeScript domain transitions on PostgreSQL”,也就是“在 PostgreSQL 上做原子化的 TypeScript 领域转换”。它把很多人没有想透的一件事点破了:域转换不能只停留在类型系统里自嗨,最终必须在数据库层原子地发生。
这篇文章不打算凭空复述 Interlock 的官方文档,而是把这个方案背后的原理拆开讲清楚:域转换到底是什么,原子性为什么是硬要求,TypeScript 和 PostgreSQL 在这个场景里各自承担什么角色,以及你在自己的项目里如何把同样的模式落地。即使你最后不引入任何新库,读完也能自己写出一套可靠的领域状态流转方案。
1. 这篇文章真正要解决的问题
先给一个明确判断:大多数业务系统里的状态机问题,表面上是“规则没写对”,实质上是“规则只存在于应用层,没有和数据库的原子性对齐”。
很多人会在 Service 里写这样的代码:先查订单,判断当前状态,再在内存里改状态,最后调用 UPDATE 保存。这段逻辑在单用户、低并发时完全正常,可一旦两个请求同时读到同一个 order 的 pending 状态,一个把它改成 paid,另一个把它改成 cancelled,数据库最后一次 UPDATE 就会覆盖前一次的结果。更危险的是,如果“判断状态”和“更新状态”之间隔了好几行代码、甚至跨了网络调用,那这段窗口期里任何并发请求都可能把数据弄坏。
Interlock 这类方案想解决的问题可以拆成三层。
第一层是状态合法性。系统里必须有且只有一份“哪些状态合法、哪些转换允许”的定义,任何新代码上线前都能通过编译期检查确认自己没有跳出转换图。
第二层是转换原子性。一次转换要么完整成功,要么什么都不发生。绝不允许出现“状态已经改了,但关联数据没写进去”的中间态。
第三层是并发正确性。两个并发请求同时尝试从同一状态转换时,只有一个能成功,另一个必须被明确拒绝,而不是默默覆盖。
什么样的读者最应该读这篇文章?如果你正在写订单、支付、审批、任务流、工单这类强状态业务;如果你已经发现自己的状态更新代码里藏着 check-then-act 的竞态;如果你希望把领域模型的类型约束和数据库事务打通,这篇文章都会对你有用。
2. 基础概念:域转换、原子性与领域状态机
2.1 什么是域转换
“域”来自领域驱动设计(DDD)里的领域模型,指业务上的一组核心概念,比如订单、用户、工单。“域转换”就是领域对象从一个合法状态迁移到另一个合法状态的过程。它比普通的状态更新多了一层约束:不是所有状态都能互相转换,转换是有向图,不是全连通图。
例如订单状态机可以定义成:
- pending(待支付)可以转到 paid、cancelled
- paid(已支付)可以转到 shipped、cancelled
- shipped(已发货)可以转到 delivered
- delivered 是终态
这类规则在代码里通常表现为一个 transition map。它看起来很简单,但它是整个系统的“业务宪法”:所有接口、所有异步任务、所有管理后台操作,都必须遵守这张图。一旦这张图在多个地方被复制粘贴、各改各的,系统很快就会失控。
2.2 什么是原子性
原子性(Atomicity)指一组操作要么全部成功、要么全部失败,不允许停留在中间状态。数据库事务天然提供这个能力:BEGIN 之后执行多条 SQL,最后 COMMIT,中间任何一步失败都可以 ROLLBACK 回滚。
在域转换场景里,原子性至少包含两个含义。一是状态本身不能出现半更新:一个订单不能被改成“已发货”但收货地址没写进去。二是状态和它关联的副作用必须一致:如果“支付成功”要同时更新订单状态和写入一条财务流水,这两件事必须在一个事务里完成,否则系统崩溃后会出现“状态是已支付、流水却是空的”这种脏数据。
这也是为什么域转换不能只靠应用层代码。应用层可以在内存里做一万次校验,但真正决定数据落盘的,是数据库事务的提交和回滚。原子性最终的裁判只能在数据库。
2.3 为什么偏偏是 TypeScript + PostgreSQL
看项目名称就知道,Interlock 选了两个目前生态里非常主流的技术:TypeScript 做编译期约束,PostgreSQL 做运行时保证。
TypeScript 的价值在于:状态机的合法转换图可以用类型系统表达出来。比如使用 as const 定义状态集合,再用 satisfies 保证 transition map 完整覆盖所有状态。这样,如果有人往状态枚举里加了一个新状态,却忘了定义它的允许转换,编译期就会报错。类型系统成了业务规则的第一个守护者。
PostgreSQL 的价值在于:它是关系型数据库里事务能力最完整、并发控制工具最丰富的选择之一。SELECT ... FOR UPDATE 可以做行级锁,UPDATE 语句自带条件原子性,ADVISORY LOCK 可以处理跨表的业务锁,SKIP LOCKED 在任务队列场景里也很有用。相比把状态存在 Redis 里、依赖应用层自旋锁的方案,PostgreSQL 能把状态、审计日志、关联业务数据放在同一个事务里,这正好是域转换最需要的特性。
当然,选择 PostgreSQL 也不是没有成本。它比简单的 KV 存储重,需要专门维护,SQL 的写法也需要团队有一定经验。但对订单、支付、审批这类一致性要求极高的业务来说,这笔成本通常非常值得。
3. 环境准备与前置条件
本文后面的参考实现以 Node.js + TypeScript + PostgreSQL 为例。版本方面请以实际项目为准,这里不绑定某个具体版本,演示的是通用思路和可复现的模式。
需要准备的环境包括:
- Node.js(建议使用当前 LTS 版本)
- TypeScript 编译器
- PostgreSQL 数据库(本地安装,或通过 Docker 运行)
- npm 或 pnpm 等包管理器
如果本机还没有 PostgreSQL,使用 Docker 启动一个临时实例是最快的办法。下面是一个最小 docker-compose.yml:
services: postgres: image: postgres:16 container_name: interlock-demo environment: POSTGRES_USER: demo POSTGRES_PASSWORD: demo POSTGRES_DB: domain_demo ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:然后执行:
docker compose up -d等容器起来之后,可以用 psql 验证连接:
docker exec -it interlock-demo psql -U demo -d domain_demo -c "select version();"如果输出 PostgreSQL 的版本信息,说明数据库已经就绪。
项目初始化部分,先创建目录并生成 package.json:
mkdir interlock-demo && cd interlock-demo npm init -y npm install pg npm install -D typescript ts-node @types/node @types/pg npx tsc --init这里用 pg 作为 PostgreSQL 驱动,ts-node 用于直接运行 TypeScript 示例。tsconfig.json 里建议开启 strict 模式,因为后面很多类型安全能力都依赖它。
{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }准备就绪后,我们进入核心部分:如何用 TypeScript 描述域转换模型。
4. 用 TypeScript 定义域转换模型
域转换模型的第一原则是:把状态和转换定义成“单一事实来源”。最好的做法是让类型的形状直接对应业务的合法状态,让编译器帮你检查有没有漏掉分支。
4.1 用字面量联合类型定义状态
// domain/order.ts export type OrderState = | 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';这是一个字符串字面量联合类型。它比 enum 更适合这里,因为数据库里存的就是字符串,联合类型可以直接和数据库值对齐,而不需要额外的映射层。
4.2 用 as const 和 satisfies 定义转换图
export const ORDER_TRANSITIONS = { pending: ['paid', 'cancelled'], paid: ['shipped', 'cancelled'], shipped: ['delivered'], delivered: [], cancelled: [], } as const satisfies Record<OrderState, readonly OrderState[]>;这里的关键是 satisfies 关键字,它是 TypeScript 4.9 引入的语法。它要求 ORDER_TRANSITIONS 必须覆盖 OrderState 里的每一个状态,同时每个状态的值必须是该状态下允许的目标状态列表。如果将来往 OrderState 里新增一个 refunding,却忘在这里补转换规则,TypeScript 会直接报错。这就是“编译期校验业务图”的威力。
4.3 类型层面推导合法目标状态
有了上面的定义,可以用类型工具推导出某个状态的合法目标:
export type AllowedTargets<S extends OrderState> = (typeof ORDER_TRANSITIONS)[S][number]; type PaidTargets = AllowedTargets<'paid'>; // 'shipped' | 'cancelled'这在写具体业务方法时非常有用:方法的入参可以直接约束为“当前状态下的合法目标”,非法调用在编译期就被拦截。
4.4 运行时校验
类型系统只在编译期生效,线上数据不会被编译。所以运行时还需要一份校验:从数据库读出来的字符串,必须验证它确实是 OrderState 的成员,才能进入后续逻辑。没有这一层,脏数据会把整个转换流程带偏。实际项目里可以用 zod 这类运行时校验库,也可以手写一个几十行的校验函数,关键是“编译期类型 + 运行时校验”两者都不缺。
5. PostgreSQL 原子转换的三种实现策略
类型定义只是图纸,真正执行转换的是数据库。下面三种策略从简单到复杂,分别适用不同场景。
5.1 单语句条件更新(最推荐的基础方案)
PostgreSQL 的 UPDATE 语句本身就带原子性:它可以在 WHERE 条件里写上“当前状态必须是 X”,然后以单行粒度原子执行。
UPDATE orders SET state = 'paid' WHERE id = 123 AND state = 'pending';这条语句的执行结果只有两种:更新了 1 行,说明状态确实从 pending 变成了 paid;更新了 0 行,说明当前状态已经不是 pending,转换被拒绝。应用层检查 rowCount 就能判断成功与否,不需要显式事务,也不需要锁。这是最简单的原子转换方案,非常适合状态本身是唯一变更对象的场景。
5.2 事务 + SELECT FOR UPDATE(需要读取和校验更多数据时)
如果转换前需要读取订单的金额、用户等级、库存等字段,才能决定本次转换是否合法,那单条 UPDATE 就不够用了。此时用显式事务加上行锁:
BEGIN; SELECT * FROM orders WHERE id = 123 FOR UPDATE; -- 应用层读取数据并做业务校验 UPDATE orders SET state = 'paid' WHERE id = 123; INSERT INTO order_log(order_id, from_state, to_state) VALUES (...); COMMIT;FOR UPDATE 会锁住这一行,直到事务结束。其他事务想改同一行时会被阻塞。这保证了“读取-校验-更新”整个过程不会被并发请求穿插。注意:锁一定要尽早拿到、事务时间尽量短,否则并发一高就容易出现锁等待甚至死锁。
5.3 用 CTE 把校验逻辑下沉到 SQL
如果希望数据库本身成为最后一道防线,可以用 CTE 把转换规则写进 SQL。这样即使应用层逻辑有 bug,数据库也能拒绝非法转换。优点是安全性高,缺点是规则在数据库和 TypeScript 之间需要保持同步,维护成本上升。对大多数团队来说,先保证应用层正确、数据库层做条件守卫,已经足够。
6. 完整示例:订单状态流转
下面用一个完整的订单状态流转示例,把前面所有概念串起来。文件结构如下:
interlock-demo/ ├── docker-compose.yml ├── package.json ├── tsconfig.json ├── schema.sql ├── src/ │ ├── domain.ts │ ├── transition.ts │ └── index.ts6.1 数据库建表
-- schema.sql CREATE TABLE IF NOT EXISTS orders ( id BIGSERIAL PRIMARY KEY, state TEXT NOT NULL DEFAULT 'pending', total_cents BIGINT NOT NULL DEFAULT 0, paid_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS order_state_history ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id), from_state TEXT NOT NULL, to_state TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_order_state_history_order_id ON order_state_history(order_id);建表之后执行:
docker exec -i interlock-demo psql -U demo -d domain_demo < schema.sql6.2 领域定义
// src/domain.ts export type OrderState = | 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'; export const ORDER_TRANSITIONS = { pending: ['paid', 'cancelled'], paid: ['shipped', 'cancelled'], shipped: ['delivered'], delivered: [], cancelled: [], } as const satisfies Record<OrderState, readonly OrderState[]>; export function assertOrderState(value: string): asserts value is OrderState { if (!(value in ORDER_TRANSITIONS)) { throw new Error(`非法状态: ${value}`); } }6.3 转换执行器
// src/transition.ts import { Pool } from 'pg'; import { OrderState, ORDER_TRANSITIONS, assertOrderState } from './domain'; export class InvalidTransitionError extends Error { constructor(from: string, to: string) { super(`不允许从 ${from} 转换到 ${to}`); this.name = 'InvalidTransitionError'; } } export class TransitionService { constructor(private pool: Pool) {} async transition(orderId: number, to: OrderState): Promise<void> { const client = await this.pool.connect(); try { await client.query('BEGIN'); // 锁定订单行,避免并发修改 const { rows } = await client.query( 'SELECT state FROM orders WHERE id = $1 FOR UPDATE', [orderId] ); if (rows.length === 0) { throw new Error(`订单不存在: ${orderId}`); } assertOrderState(rows[0].state); const from = rows[0].state; if (!ORDER_TRANSITIONS[from].includes(to)) { throw new InvalidTransitionError(from, to); } // 携带原状态条件再次更新,作为数据库层最后一道防线 const result = await client.query( `UPDATE orders SET state = $2, updated_at = now() WHERE id = $1 AND state = $3`, [orderId, to, from] ); if (result.rowCount !== 1) { throw new Error(`订单状态已被其他事务修改: ${orderId}`); } await client.query( `INSERT INTO order_state_history(order_id, from_state, to_state) VALUES ($1, $2, $3)`, [orderId, from, to] ); await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } }这段代码的关键点有三个:FOR UPDATE 保证同一时间只有一个事务能读取并修改该订单;应用层先校验转换图,非法转换直接抛错;UPDATE 的 WHERE 里再带一次原状态,即使应用层判断和数据库更新之间发生极端情况,数据库也会拒绝不一致的更新。每次转换都会写入一条审计历史,保证任何时候都能追溯状态的变化轨迹。
6.4 调用入口
// src/index.ts import { Pool } from 'pg'; import { TransitionService } from './transition'; async function main() { const pool = new Pool({ connectionString: 'postgres://demo:demo@localhost:5432/domain_demo', }); const service = new TransitionService(pool); // 插入一个测试订单 const inserted = await pool.query( `INSERT INTO orders(total_cents) VALUES (9900) RETURNING id` ); const orderId = Number(inserted.rows[0].id); // 正常转换 await service.transition(orderId, 'paid'); console.log('pending -> paid 成功'); // 尝试非法转换:paid 不允许直接到 delivered try { await service.transition(orderId, 'delivered'); } catch (error) { console.log('非法转换被拒绝:', (error as Error).message); } // 正确路径 await service.transition(orderId, 'shipped'); await service.transition(orderId, 'delivered'); console.log('shipped -> delivered 成功'); const result = await pool.query( 'SELECT state FROM orders WHERE id = $1', [orderId] ); console.log('最终状态:', result.rows[0].state); await pool.end(); } main().catch((error) => { console.error(error); process.exit(1); });运行方式:
npx ts-node src/index.ts预期输出类似:
pending -> paid 成功 非法转换被拒绝: 不允许从 paid 转换到 delivered shipped -> delivered 成功 最终状态: delivered从输出可以确认:合法转换能通过,非法转换会被拒绝,最终状态符合状态机定义。
7. 运行验证与效果检查
代码能跑通只是第一步。域转换方案最需要验证的是并发场景下的行为。用一个简单的并发测试来模拟两个请求同时尝试从 pending 转换到不同状态。
// src/concurrency-test.ts import { Pool } from 'pg'; import { TransitionService } from './transition'; async function concurrencyTest() { const pool = new Pool({ connectionString: 'postgres://demo:demo@localhost:5432/domain_demo', }); const service = new TransitionService(pool); const inserted = await pool.query( `INSERT INTO orders(total_cents) VALUES (5000) RETURNING id` ); const orderId = Number(inserted.rows[0].id); // 同时发起两个转换:一个到 paid,一个到 cancelled const results = await Promise.allSettled([ service.transition(orderId, 'paid'), service.transition(orderId, 'cancelled'), ]); results.forEach((result, index) => { console.log( `请求${index + 1}:`, result.status === 'fulfilled' ? '成功' : `失败: ${result.reason.message}` ); }); const { rows } = await pool.query( 'SELECT state FROM orders WHERE id = $1', [orderId] ); console.log('最终状态:', rows[0].state); await pool.end(); } concurrencyTest();运行后,理想结果是两个请求一个成功、一个失败,最终状态只有 paid 或 cancelled 中的一种,绝不会出现两个都成功。
请求1: 成功 请求2: 失败: 订单状态已被其他事务修改: 1 最终状态: paid具体谁成功不固定,取决于谁先拿到行锁,但无论哪种顺序,数据库里都不会出现互相覆盖的脏状态。这就是原子转换和普通 check-then-act 的本质区别。
如果验证时发现两个请求都成功了,第一步应该查事务隔离级别和 FOR UPDATE 是否真的加了锁;如果发现死锁,检查是否在同一个事务里按不同顺序锁了多行。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 两个并发请求都成功 | 没使用 FOR UPDATE 或条件 UPDATE | 查看代码是否在事务内锁行,检查事务是否及时 COMMIT | 使用 5.1 的条件更新或 5.2 的 SELECT FOR UPDATE |
| 非法转换没有报错 | 运行时校验缺失,数据库值不在联合类型内 | 检查 assertOrderState 是否执行 | 在读取状态后立即做运行时校验 |
| 转换时报“订单不存在” | 订单 ID 错误,或事务隔离 |