尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

TypeScript与PostgreSQL实现原子化领域状态转换的实践指南

TypeScript与PostgreSQL实现原子化领域状态转换的实践指南 在几乎所有业务系统里都能遇到一类看起来非常简单、做起来却异常折磨人的需求订单只能从“待支付”变成“已支付”绝不能从“已支付”变回“待支付”退款单一旦进入“审批通过”就不能再被用户撤销任务的状态流转必须走固定路径跳级会破坏整个流程。这类规则如果只在代码里写 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、cancelledpaid已支付可以转到 shipped、cancelledshipped已发货可以转到 delivereddelivered 是终态这类规则在代码里通常表现为一个 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.ymlservices: 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.jsonmkdir 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 RecordOrderState, readonly OrderState[];这里的关键是 satisfies 关键字它是 TypeScript 4.9 引入的语法。它要求 ORDER_TRANSITIONS 必须覆盖 OrderState 里的每一个状态同时每个状态的值必须是该状态下允许的目标状态列表。如果将来往 OrderState 里新增一个 refunding却忘在这里补转换规则TypeScript 会直接报错。这就是“编译期校验业务图”的威力。4.3 类型层面推导合法目标状态有了上面的定义可以用类型工具推导出某个状态的合法目标export type AllowedTargetsS extends OrderState (typeof ORDER_TRANSITIONS)[S][number]; type PaidTargets AllowedTargetspaid; // 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 RecordOrderState, 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): Promisevoid { 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:demolocalhost: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:demolocalhost: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 错误或事务隔离
返回列表