尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于TypeScript与PostgreSQL实现原子化领域状态转换

基于TypeScript与PostgreSQL实现原子化领域状态转换 在做订单、审批、支付这类业务时领域对象的状态转换是最容易出现并发问题的环节。最近看到 Show HN 上的一个项目 Interlock它把 TypeScript 领域转换与 PostgreSQL 的数据库事务能力放到了一起。项目标题里的几个词值得拆开来看Atomic、TypeScript、PostgreSQL、domain transitions。每一个词都对应一个工程痛点。这篇文章不打算贴 Interlock 的源码而是把这类方案背后的工程设计拆解清楚为什么要用 TypeScript 定义领域状态机为什么要靠 PostgreSQL 的事务和锁保证原子性二者如何结合成一套可运行的代码。我会用一个“订单状态流转”的完整案例从建库、建表、写 TypeScript 服务到模拟并发调用带你看完整条链路。在开始之前先明确本文的读者范围和阅读收益如果你正在做订单系统、审批流、发布系统、任务调度系统这类“状态会变化”的业务本文适合你。如果你刚接触 TypeScript PostgreSQL希望掌握事务、锁、状态机的基本用法本文也适合你。学完本文你会理解如何让应用层类型定义与数据库约束保持一致如何用事务和锁避免并发覆盖以及遇到死锁、序列化冲突时如何排查。1. 背景与核心概念1.1 Interlock 是什么Interlock 这个名字很形象interlock 本身就是“互锁”的意思。从 Show HN 项目标题来看它希望解决的问题是当你在 TypeScript 中定义了一个领域对象这个对象的状态会发生转变而状态转变又要落到 PostgreSQL 里怎么保证这个过程是不出错的、原子的只用 TypeScript 定义状态机无法避免多个服务实例同时修改同一条数据的问题只用 PostgreSQL 事务又缺少对业务状态合法性的建模。Interlock 这类方案的思路是把 TypeScript 的类型定义、PostgreSQL 的约束、数据库事务三者“互锁”起来类型层用 TypeScript 联合类型或枚举约束合法状态。约束层用 PostgreSQL 的 CHECK、外键、触发器保证非法状态在数据库侧也进不去。原子层用事务、行锁、隔离级别保证状态转换在并发和异常下不会出现中间态。如果把这三层拆开每一层其实都不算新东西。很多项目的问题在于层与层之间是割裂的应用层有一套状态判断逻辑数据库层没有约束或者应用层有事务但没加锁并发一上来就出问题。Interlock 的立足点就是把这些机制拼装成一套可落地的方案。1.2 domain transitions领域转换是什么项目标题里的 domain transitions在工程语境下可以理解为“领域对象的状态迁移”。比如订单从 pending 变成 paid。任务从 todo 变成 done。内容从 draft 变成 published。退款申请从 submitted 变成 approved。这些都不是简单的字段修改而是带有业务规则的迁移。一个订单从 pending 可以直接变成 cancelled但通常不能从 delivered 再变回 pending一个已退款完成的订单也不能再次从 paid 变成 shipped。如果只是用UPDATE orders SET status paid WHERE id ...来操作那么状态机的规则只能靠应用层自觉。一旦有新的调用方、新的服务实例或者某个代码分支忘了判断非法状态转换就会进入数据库。domain transitions 的核心诉求是让这种迁移本身成为一个可以被校验、被并发保护、被回滚的独立单元。Interlock 把 TypeScript 领域类型和 PostgreSQL 放在一起本质上是把“状态机的描述”和“状态机的执行”统一起来。1.3 为什么需要原子性原子性Atomicity在数据库领域意味着一个事务里的操作要么全部成功要么全部失败不允许出现“改了一半”的情况。在订单场景下支付成功后的状态转换可能包含多个动作把订单状态从 pending 改为 paid。扣减商品库存。写入支付流水。如果第一步成功第二步失败订单已经变成 paid但库存没有扣减这就产生了数据不一致。原子性要求这三个动作在同一个事务里任何一个失败都要整体回滚。另一个更容易被忽略的问题是并发。两个请求同时读到订单状态是 pending一个请求尝试改成 paid另一个请求尝试改成 cancelled。如果没有任何锁机制后提交的事务可能覆盖先提交的结果。更准确地说这不是回滚的问题而是更新丢失lost update的问题。PostgreSQL 的 UPDATE 语句本身会锁定行但如果你的业务逻辑是先读后写且没有在事务中锁住读取的行就无法保证判断状态时的值和更新时的值是一致的。2. 原子领域转换的工程难点在进入代码之前有必要把问题模型说清楚。很多人以为“加个事务”就能解决所有问题实际并非如此。事务只是保证了一致性边界但并发环境下的竞态、应用层状态机与数据库状态的不一致仍然会让系统出现难以察觉的 Bug。2.1 难点一应用层状态机与数据库状态不一致很多系统一开始只有一张表和一个status字段应用层通过 if/else 控制状态流转if (order.status pending) { await updateOrderStatus(order.id, paid); }这段代码在单机、低并发下没有问题。但随着业务复杂化会出现下面几种情况多个模块都需要改订单状态各自复制了一段判断逻辑规则慢慢偏离。某个定时任务直接用 SQL 批量更新状态绕过了应用层校验。数据分析脚本、数据订正脚本直接改库没有走业务接口。结果就是应用层状态机成了摆设数据库里出现“不可能的状态组合”。例如订单是delivered但支付流水为空或者任务已经cancelled却又被completed覆盖。要解决这个问题仅仅靠应用层是不行的。状态机规则必须下沉到数据库层至少要能对非法的状态转换说不。2.2 难点二并发更新带来的状态覆盖下面是典型的问题场景事务 A 读取订单状态为 pending。事务 B 读取订单状态为 pending。事务 A 将状态改为 paid提交成功。事务 B 将状态改为 cancelled提交成功。最终数据库里的状态是 cancelled但实际业务上用户已经支付成功。更糟糕的是B 在更新时可能还改了其他字段把 A 的修改完全覆盖了。有人会说用UPDATE ... WHERE status pending就能解决。确实这条语句在数据库层面会锁行只有一个事务能成功。但如果你在应用层先 SELECT 再判断判断通过后进程被 GC 暂停、网络延迟或某个外部调用阻塞了几十毫秒另一个事务可能已经改了状态。等到当前事务执行 UPDATE 时状态早已不是当初读到的值。所以要么用SELECT ... FOR UPDATE在事务一开始就锁住这行要么用乐观锁的版本号机制在更新时校验版本。这都不是靠“加个事务”就能天然得到的。2.3 难点三失败回滚不完整事务回滚能保证数据库的原子性但无法保证外部调用的原子性。如果你的状态转换中间调用了第三方支付接口、消息队列、外部 HTTP API而这些调用发生时事务还没提交一旦后续步骤失败事务回滚了数据库外部系统却已经执行了动作就会产生补偿问题。Interlock 这类方案通常会把重点放在数据库内部的状态转换上因为这是一致性的源头。而外部调用的补偿属于分布式事务的范畴需要通过消息表、本地消息、Saga 等模式去处理。这里我建议遵循一个原则事务内只做数据库操作不调用外部系统。把外部调用放在事务提交之后通过 outbox 消息表或者事件订阅来触发。这样即使外部调用失败数据库本身不会出现半截状态。3. 环境准备与项目初始化下面进入实战环节。我们来实现一个简化版的订单状态转换服务体验“TypeScript 类型 PostgreSQL 约束 数据库事务”三者互锁的效果。3.1 环境要求本文示例使用的环境如下你可以根据自己的系统调整Node.js 18 及以上。TypeScript 5.x。PostgreSQL 15 或 16本文以 16 为例。Docker Desktop 或本机安装的 PostgreSQL。包管理工具使用 npm。如果你本机还没有安装 PostgreSQL推荐先用 Docker 启动一个实例后面调试完可以随时删除不影响本机环境。如果你的电脑上已经装好了 PostgreSQL可以跳过 Docker 步骤直接创建数据库和账号。3.2 用 Docker 启动 PostgreSQL在项目目录下创建docker-compose.ymlservices: postgres: image: postgres:16-alpine container_name: interlock-demo-pg environment: POSTGRES_USER: interlock POSTGRES_PASSWORD: interlock POSTGRES_DB: interlock_demo ports: - 5432:5432 volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U interlock] interval: 5s timeout: 5s retries: 5 volumes: pgdata:然后执行docker compose up -d等容器启动后确认连接是否正常docker exec -it interlock-demo-pg psql -U interlock -d interlock_demo -c SELECT version();如果你不用 Docker而是本机安装 PostgreSQL则需要在本地创建一个用户和数据库CREATE USER interlock WITH PASSWORD interlock; CREATE DATABASE interlock_demo OWNER interlock;3.3 初始化 TypeScript 项目创建项目目录并初始化mkdir interlock-demo cd interlock-demo npm init -y安装依赖npm install pg npm install -D typescript tsx types/node types/pg创建tsconfig.json{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true }, include: [src] }这里稍微提一下 TypeScript 配置的注意点。网上很多旧教程会配置baseUrl以简化 import 路径但 TypeScript 官方已经在逐步收紧这类配置TS 7.0 中baseUrl将停止工作。新项目建议直接使用NodeNext模块解析import 相对路径老老实实带.js后缀避免将来升级工具链时踩坑。项目目录结构规划如下interlock-demo ├── docker-compose.yml ├── package.json ├── tsconfig.json ├── migrations │ └── 001_create_orders.sql └── src ├── db.ts ├── types.ts ├── stateMachine.ts ├── orderService.ts └── main.ts4. 领域模型与数据模型设计4.1 定义 TypeScript 领域类型我们以订单为例。订单状态包含pending、paid、shipped、delivered、cancelled。从业务规则来看pending可以到paid或cancelled。paid可以到shipped或cancelled。shipped可以到delivered。delivered是终态。cancelled是终态。先创建src/types.tsexport const ORDER_TRANSITIONS { pending: [paid, cancelled], paid: [shipped, cancelled], shipped: [delivered], delivered: [], cancelled: [], } as const; export type OrderStatus keyof typeof ORDER_TRANSITIONS; export interface Order { id: string; status: OrderStatus; amountCents: number; version: number; updatedAt: Date; }这里用as const有两个好处OrderStatus会自动成为pending | paid | shipped | delivered | cancelled的联合类型。状态转换表成为不可变对象避免运行期被意外修改。这种写法的核心思路是“单一数据源”。状态机和订单类型来自同一份定义避免在多个文件里维护两份可能不一致的状态列表。再创建src/stateMachine.ts作为状态转换校验函数import { ORDER_TRANSITIONS, OrderStatus } from ./types.js; export function assertTransition(from: OrderStatus, to: OrderStatus): void { const allowed ORDER_TRANSITIONS[from] as readonly string[]; if (!allowed.includes(to)) { throw new Error(非法状态转换: ${from} - ${to}); } } export function canTransition(from: OrderStatus, to: OrderStatus): boolean { return (ORDER_TRANSITIONS[from] as readonly string[]).includes(to); }注意 NodeNext 模块解析下TypeScript 源码里相对导入需要写.js后缀。这是 ESM 的约束很多初学 NodeNext 的开发者会在这里报ERR_MODULE_NOT_FOUND。4.2 定义 PostgreSQL 表结构创建migrations/001_create_orders.sqlCREATE TABLE IF NOT EXISTS orders ( id UUID PRIMARY KEY, status TEXT NOT NULL CHECK (status IN (pending, paid, shipped, delivered, cancelled)), amount_cents BIGINT NOT NULL CHECK (amount_cents 0), version BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );这条建表语句有几点值得说status使用 TEXT 类型加上 CHECK 约束。PostgreSQL 虽然没有原生的 ENUM 性能问题但 CHECK 约束更方便迁移和扩展。CHECK (status IN (...))保证了数据库层面只接受这五种状态应用层传什么非法值都会被拒。amount_cents用 BIGINT金额以“分”为单位避免浮点数误差。这是金融业务里的基本常识。version用于乐观锁后面会用到。updated_at使用带时区的TIMESTAMPTZ避免时区混乱。这里顺便说一句很多 TypeScript 项目会引入 ORM让 ORM 去自动建表。但 ORM 自动生成的表结构往往只有类型没有约束。类型只是编译时的合约数据库约束才是运行时的最后防线。这也是 Interlock 这类方案强调数据库约束的原因。4.3 状态转换规则表与数据库触发器仅仅有 CHECK 约束还不够它只能保证 status 是合法枚举值无法保证“从 paid 改为 delivered”是非法的。要限制转换路径需要一张规则表和一个触发器。在001_create_orders.sql中追加内容CREATE TABLE IF NOT EXISTS order_transition_rules ( from_status TEXT NOT NULL, to_status TEXT NOT NULL, PRIMARY KEY (from_status, to_status) ); INSERT INTO order_transition_rules (from_status, to_status) VALUES (pending, paid), (pending, cancelled), (paid, shipped), (paid, cancelled), (shipped, delivered) ON CONFLICT DO NOTHING; CREATE OR REPLACE FUNCTION enforce_order_transition() RETURNS trigger AS $$ BEGIN IF NEW.status IS DISTINCT FROM OLD.status THEN IF NOT EXISTS ( SELECT 1 FROM order_transition_rules WHERE from_status OLD.status AND to_status NEW.status ) THEN RAISE EXCEPTION 非法订单状态转换: % - %, OLD.status, NEW.status; END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_enforce_order_transition ON orders; CREATE TRIGGER trg_enforce_order_transition BEFORE UPDATE OF status ON orders FOR EACH ROW EXECUTE FUNCTION enforce_order_transition();这段 SQL 的作用是建一张状态转换规则表相当于把 TypeScript 里的ORDER_TRANSITIONS在数据库里复制了一份。在 orders 表上建立触发器当 status 字段发生更新时自动检查旧状态到新状态的路径是否在规则表中。如果规则表里不存在该路径直接抛异常事务回滚。这样就算有人绕过应用层直接执行UPDATE orders SET status delivered WHERE status pending数据库也会拒绝。有读者可能会问规则表也存在维护成本TypeScript 改状态机时数据库规则表也得同步改。这是事实但换个角度看这种“冗余”是刻意为之。应用层校验是为了给请求方快速反馈数据库校验是为了防止意外。生产系统里数据安全的价值远大于多维护一张表带来的成本。5. TypeScript 中的原子状态转换实现5.1 最简单的事务写法先看一个最简单的、只保证原子性但不考虑并发的写法。创建src/db.tsimport pg from pg; const { Pool } pg; export const pool new Pool({ connectionString: process.env.DATABASE_URL ?? postgres://interlock:interlocklocalhost:5432/interlock_demo, });创建src/orderService.ts先实现一个简单的版本import { pool } from ./db.js; import { OrderStatus } from ./types.js; export async function updateOrderStatus(id: string, status: OrderStatus) { await pool.query( UPDATE orders SET status $1, version version 1, updated_at now() WHERE id $2, [status, id] ); }这种写法的问题很明显它无条件更新。应用层哪怕先 SELECT 判断也无法避免并发场景下的检查后再更新竞态。5.2 悲观锁SELECT ... FOR UPDATEPostgreSQL 提供行级锁。SELECT ... FOR UPDATE会锁定查询到的行直到当前事务结束。其他事务要更新这行会被阻塞直到锁释放。把orderService.ts改造成事务版本import { pool } from ./db.js; import { Order, OrderStatus } from ./types.js; import { assertTransition } from ./stateMachine.js; export async function transitionOrder( id: string, toStatus: OrderStatus ): PromiseOrder { const client await pool.connect(); try { await client.query(BEGIN); // 1. 读取并锁定目标行 const lockResult await client.query( SELECT id, status, amount_cents, version, updated_at FROM orders WHERE id $1 FOR UPDATE, [id] ); if (lockResult.rowCount 0) { throw new Error(订单不存在: ${id}); } const current mapRowToOrder(lockResult.rows[0]); // 2. 应用层状态机校验 assertTransition(current.status, toStatus); // 3. 执行更新 const updateResult await client.query( UPDATE orders SET status $1, version version 1, updated_at now() WHERE id $2 RETURNING id, status, amount_cents, version, updated_at, [toStatus, id] ); await client.query(COMMIT); return mapRowToOrder(updateResult.rows[0]); } catch (error) { await client.query(ROLLBACK); throw error; } finally { client.release(); } } function mapRowToOrder(row: any): Order { return { id: row.id, status: row.status, amountCents: Number(row.amount_cents), version: Number(row.version), updatedAt: row.updated_at, }; }这个写法有几个关键点BEGIN开启事务事务结束后COMMIT任何一步失败都ROLLBACK。SELECT ... FOR UPDATE锁住了订单行后续其他事务对该行的更新会阻塞直到当前事务提交。assertTransition在应用层先做一次状态机校验这是为了快速失败避免无效更新。ROWLOCK被锁的时间是“整个事务”不是单条语句。所以事务里尽量减少其他耗时操作。悲观锁适合状态转换比较频繁、冲突概率较高的场景。它的缺点是并发量高时行锁会造成一定阻塞等待。5.3 乐观锁version 字段如果你希望减少锁等待可以考虑乐观锁。核心思路是在读取数据时拿到 version更新时把 version 也放在 WHERE 条件里如果 version 已经被其他事务修改更新影响行数为 0说明发生了并发冲突。export async function transitionOrderOptimistic( id: string, expectedVersion: number, toStatus: OrderStatus ): PromiseOrder { const result await pool.query( UPDATE orders SET status $1, version version 1, updated_at now() WHERE id $2 AND version $3 RETURNING id, status, amount_cents, version, updated_at, [toStatus, id, expectedVersion] ); if (result.rowCount 0) { throw new Error(版本冲突订单 ${id} 已被其他事务修改); } return mapRowToOrder(result.rows[0]); }乐观锁的缺点是发生冲突后你需要重新读取订单、重新校验状态机、再执行一次更新。在高冲突场景下会出现大量无效重试。但它不需要长时间持有行锁对长事务更友好。在实际项目里选择哪种方案取决于业务。下单、支付这类冲突率高的场景用悲观锁更直观日志、评论、任务状态这类冲突率低的场景用乐观锁更合适。5.4 数据库触发器与应用层的兜底关系现在我们有两条校验路径应用层assertTransition负责快速反馈错误避免无效数据库操作。数据库触发器enforce_order_transition负责最终防线。两条路径的规则必须保持一致。在开发中我建议把 TypeScript 状态矩阵作为基础数据库规则表通过迁移脚本同步维护。每次修改状态机时必须同时提交应用层代码和 SQL 迁移在 Code Review 时检查两者是否匹配。6. 完整实战案例这一节我们把上面的代码整合成一个可以跑起来的 Demo。6.1 项目文件结构最终的项目结构如下interlock-demo ├── docker-compose.yml ├── package.json ├── tsconfig.json ├── migrations │ └── 001_create_orders.sql └── src ├── db.ts ├── types.ts ├── stateMachine.ts ├── orderService.ts └── main.ts6.2 执行数据库迁移先启动数据库docker compose up -d再执行迁移脚本docker exec -i interlock-demo-pg psql -U interlock -d interlock_demo migrations/001_create_orders.sql如果没有 Docker而是本机安装的 PostgreSQL可以直接用 psql 执行psql -h localhost -U interlock -d interlock_demo -f migrations/001_create_orders.sql执行后可以确认表结构\d orders你应该能看到orders表、CHECK 约束和触发器trg_enforce_order_transition。6.3 编写核心代码在之前的基础上补充src/main.tsimport { randomUUID } from node:crypto; import { pool } from ./db.js; import { transitionOrder } from ./orderService.js; async function main() { const orderId randomUUID(); await pool.query( INSERT INTO orders (id, status, amount_cents) VALUES ($1, pending, 9900), [orderId] ); console.log(已创建订单ID , orderId); const paid await transitionOrder(orderId, paid); console.log(订单已支付当前状态 ${paid.status}); const shipped await transitionOrder(orderId, shipped); console.log(订单已发货当前状态 ${shipped.status}); const delivered await transitionOrder(orderId, delivered); console.log(订单已送达当前状态 ${delivered.status}); try { await transitionOrder(orderId, paid); } catch (error: any) { console.log(非法转换被拦截, error.message); } await pool.end(); } main().catch(console.error);main.ts的逻辑很简单随机生成一个 UUID 作为订单 ID。插入一条状态为pending的订单。依次把订单转换为paid、shipped、delivered。尝试再次从delivered转换为paid这一步会被状态机拦截。6.4 在 package.json 中加入启动脚本修改package.json的 scripts 部分{ scripts: { start: tsx src/main.ts } }然后运行npm start6.5 预期结果正常情况下控制台输出应该是已创建订单ID 4d2f15d6-1f6b-4a3d-9f42-2e5d70d3c1a7 订单已支付当前状态 paid 订单已发货当前状态 shipped 订单已送达当前状态 delivered 非法转换被拦截非法状态转换: delivered - paid同时如果你想验证数据库触发器是否生效可以手动执行一条绕过应用层的 UPDATEUPDATE orders SET status paid WHERE status delivered;你会收到数据库的异常ERROR: 非法订单状态转换: delivered - paid这就达到了“应用层和数据库层双重拦截”的效果。6.6 并发验证思路要验证原子性和并发保护可以同时发起两个转换请求npm start npm start 这里因为两个进程会各自插入不同的随机订单不会冲突。更真实的验证方式是写一个小脚本线程并发地把同一笔订单从pending同时改为paid和cancelled然后观察最终状态只有一种且另一个请求会失败或等待。由于SELECT ... FOR UPDATE会阻塞第二个事务第二个事务会在第一个提交后读到最新状态然后被应用层assertTransition拦截。7. 常见问题与排查思路在实现类型与数据库互锁的方案时经常会遇到下面几类问题。我把它们整理成表格方便你快速定位。问题现象常见原因解决思路事务执行中出现ERROR: current transaction is aborted前面某条 SQL 抛错后没有及时回滚后续 SQL 继续执行catch 中必须执行 ROLLBACK后续代码不要继续复用同一 client并发更新时出现40001 serialization_failure使用了 SERIALIZABLE 隔离级别但未做重试捕获 40001 错误重新读取数据后重试业务冲突率不高时可用 READ COMMITTED并发更新时出现40P01 deadlock_detected多个事务按不同顺序锁定了多行数据统一锁定顺序锁比较多时先排序再锁捕获死锁错误做重试更新影响行数为 0但数据确实存在乐观锁 version 不匹配数据已被其他事务修改重新读取最新数据让用户确认后再次提交触发器报非法订单状态转换应用层和数据库状态机规则不一致检查 TypeScript 状态矩阵和 order_transition_rules 是否一致pg返回的 BIGINT 变成字符串PostgreSQL 驱动对 BIGINT 默认使用字符串序列化使用Number(row.amount_cents)转换或配置 pg 类型解析器NodeNext 模式下 import 报ERR_MODULE_NOT_FOUND相对导入没有写.js后缀把./types改为./types.js除了上面这些还有一个常见问题是事务范围过大。有些同学把外部 HTTP 请求也放进事务里导致数据库连接长时间被占用。遇到这种情况建议把事务严格控制在数据库操作边界外部调用放到事务提交之后。8. 最佳实践与工程建议8.1 让 TypeScript 类型成为唯一事实来源状态定义、状态转换矩阵、订单实体类型尽量在同一个 TypeScript 文件里通过as const推导。这样可以避免手写重复的类型定义。但需要注意数据库规则表和应用层状态矩阵的同步仍然要靠迁移脚本。理想情况下你可以写一个脚本从ORDER_TRANSITIONS自动生成INSERT INTO order_transition_rules的 SQL。这样就把“互锁”从代码层推进到了构建层减少人工同步的失误。8.2 事务与锁的使用原则事务尽量短避免在事务中做网络调用、发消息、等待锁之外的事情。事务中涉及的多个行尽量按相同的顺序加锁减少死锁概率。SELECT ... FOR UPDATE要配合条件索引使用否则全表扫描会锁定不必要的行。乐观锁适合读多写少的场景悲观锁适合状态冲突率高的场景。如果使用 SERIALIZABLE 隔离级别务必实现捕获40001后的重试机制。8.3 数据库约束不能省我见过很多项目应用层写得非常规范但数据库表没有 CHECK、没有外键、没有触发器。等到数据订正脚本一跑或者某个历史接口绕过逻辑脏数据就进来了。数据库的约束本质上是“保险丝”。平时不触发但一旦触发就能避免重大事故。状态转换这种高风险操作宁可在迁移阶段多花时间建规则表也不要在生产环境中靠半夜人工修数据。8.4 测试与发布策略针对状态转换代码至少要有以下几类测试单元测试验证状态机矩阵的合法性比如每个状态的所有出边是否都被允许。集成测试连接真实 PostgreSQL验证合法转换成功、非法转换失败。并发测试模拟两个事务同时转换同一订单验证只有一个成功。迁移测试在 CI 环境中从空库执行迁移脚本再跑完整测试用例保证迁移脚本可重复执行。发布时先执行数据库迁移脚本再发布应用代码。如果应用代码依赖新的状态但迁移还没执行会导致运行时报错。8.5 性能与可观测性状态转换的核心性能瓶颈通常不在应用代码而在锁等待和索引。orders表一定要对id建主键索引这是默认必有的。如果经常按某种业务 ID 查询状态记得额外建索引。日志方面建议记录以下信息事务开始和结束时间。锁等待时间。状态
返回列表