Postgres 事务用于多步工作流:实用模式
学习 Postgres 事务在多步工作流中的实践:如何安全地将更新分组、避免部分写入、处理重试并保持数据一致性。

为什么多步更新常常变得不一致
大多数真实功能不是一次数据库更新就完事的。它们通常是一小段链:插入一行、更新余额、标记状态、写审计记录,也许还要入队一个作业。部分写入就是只有其中某些步骤写进了数据库。
这通常出现在链路被打断的时候:服务器错误、应用和 Postgres 之间的超时、在第 2 步后崩溃,或者重试重跑了第 1 步。每条语句本身都没问题。问题在于工作流中途停止时出现的差异。
你通常可以快速发现这些问题:
- 有一行存在,但相关的行缺失(订单已创建,但没有商品项)
- 钱已经移动,但状态没有改变(已支付,但仍显示未支付)
- 本该合并成一条的记录变成了两条(重试后重复订阅)
- 标志互相冲突(用户是 “active”,但没有任何 plan)
- 仅在高并发或故障时出现的状态
一个具体例子:升级套餐需要更新客户的 plan、添加一条支付记录、并增加可用额度。如果应用在保存支付记录后但在增加额度前崩溃,客服会看到一张表里显示“已付款”,另一张表却显示“无额度”。如果客户端重试,你甚至可能记录了两次支付。
目标很简单:把整个工作流当成一个开关。要么所有步骤都成功,要么都不生效,这样就不会存下半截的工作。
用通俗的话说事务
事务是数据库告诉你:把这些步骤视为一个工作单元。要么所有更改都发生,要么都不发生。每当你的工作流需要多个更新(比如创建一行、更新余额、写审计记录)时,这点就很重要。
想象在两个账户之间转账。你必须从账户 A 扣款并给账户 B 记入。如果应用在第一步之后崩溃,你不希望系统只记住了扣款这一半动作。
提交 vs 回滚
当你**提交(commit)**时,你告诉 Postgres:保留我在这个事务里做的所有事情。所有更改变为永久并对其他会话可见。
当你**回滚(rollback)**时,你告诉 Postgres:忘掉我在这个事务里做的所有事情。Postgres 会把这些更改撤销,就像事务从未发生过一样。
Postgres 保证什么(以及不保证什么)
在事务内部,Postgres 保证在你提交前不会把半成品暴露给其他会话。如果某些操作失败并且你回滚,数据库会清理该事务的写入。
事务并不能修复糟糕的工作流设计。如果你扣错了金额、用了错误的用户 ID、或跳过了必要检查,Postgres 会忠实地提交错误的结果。事务也不会自动阻止所有业务级别的冲突(比如超卖库存),除非你配合了合适的约束、锁或隔离级别。
应该分组的工作流
任何时候,当你为了完成一个现实世界的动作需要更新多张表(或多行)时,这就是事务的候选场景。核心思想不变:要么一切完成,要么什么都不做。
订单流程是经典案例。你可能会创建订单行、预留库存、扣款,然后把订单标为已支付。如果支付成功但状态更新失败,你就会有钱被捕获但订单看起来仍是未支付。如果订单行创建了但库存没有预留,你就可能卖出实际并不存在的商品。
用户入职同样会悄然出问题。创建用户、插入 profile、分配角色、记录应发送的欢迎邮件,这些是一个逻辑动作。如果不分组,你可能得到可以登录但没有权限的用户,或者存在没有用户的 profile。
后台办公操作常常需要严格的“凭证 + 状态变更”行为。批准请求、写审计条目、更新余额这些操作应该一起成功。如果余额变了但审计日志缺失,你就会丢失谁在什么时间为什么改变了数据的证据。
后台任务也受益于事务,特别是当你处理一个工作项包含多步时:先认领任务以避免两个 worker 同时处理,应用业务更新,记录结果以便上报与重试,然后把项标为完成(或以失败原因为失败)。如果这些步骤分散开,重试和并发会把事情弄得一团糟。
在写 SQL 之前设计工作流
当你把多步功能当成一堆独立更新时,它们就会出问题。在打开数据库客户端之前,把工作流写成一个简短的故事,并给出一个清晰的完成标准:对用户来说“完成”到底是什么意思?
先用普通语言列出步骤,然后定义一个单一的成功条件。例如:“订单已创建、库存已预留、用户能看到一个订单确认号。”哪怕只有部分表被更新,也不能算成功。
接下来,把数据库工作和外部工作划清界限。数据库步骤是你可以用事务保护的。像银行卡扣款、发送邮件或调用第三方 API 这类外部调用会以慢而不可预测的方式失败,而且通常无法回滚。
一个简单的规划方法:把步骤分成(1)必须是全有或全无的、(2)可以在提交后发生的。
决定哪些该放进事务
在事务内只保留那些必须保持一致的步骤:
- 创建或更新核心行(订单、发票、账户余额)
- 预留共享资源(库存、座位、配额)
- 记录持久的“下一步要做”的事件(outbox 表)
- 用约束强制规则(唯一键、外键)
把副作用移到外面。例如,先提交订单,然后根据 outbox 记录发送确认邮件。
为每步写明回滚预期
为每一步写明下一个步骤失败时应发生什么。“回滚”可以意味着数据库回滚,或者采取补偿动作。
示例:如果支付成功但库存预留失败,事先决定是立即退款,还是把订单标记为“已收款,等待配货”并异步处理。
逐步示例:把工作流包进事务
事务告诉 Postgres:把这些步骤视为一个单元。它们要么全部发生,要么全部不发生。这是防止部分写入的最简单方法。
基本流程
从头到尾使用一个数据库连接(一个会话)。如果你把步骤分散到不同连接上,Postgres 无法保证全有或全无的结果。
顺序很直接:BEGIN,执行必要的读取和写入;如果一切成功则 COMMIT,否则 ROLLBACK 并返回清晰的错误。
下面是一个最小的 SQL 示例:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
保持事务短小(且便于调试)
事务在运行时会持有锁。你把它们保持打开的时间越长,就越会阻塞其他工作,也越容易遇到超时或死锁。把必需的事情放进事务,把慢操作(发送邮件、调用支付提供商、生成 PDF)移到外面。
当发生错误时,记录足够的上下文以便重现问题,但不要泄露敏感数据:工作流名称、order_id 或 user_id、关键参数(金额、货币)、以及 Postgres 错误码。避免记录完整载荷、卡数据或个人详情。
并发基础:不用术语也能懂的锁与隔离
并发只是两件事同时发生。想象两位顾客同时试图买最后一张演唱会票。两个页面都显示“仅剩 1 张”,两人都点了付款,现在你的应用要决定把票给谁。
如果没有保护,两个请求都可能读取相同的旧值并都写入更新。这就是出现负库存、重复预订或支付但没有订单的原因。
行锁是最简单的防护手段。锁住你要改的那一行,做检查,然后更新。其他触碰同一行的事务必须等待你提交或回滚,这样就能防止重复更新。
常见模式是:开启事务,用 FOR UPDATE 选中库存行,验证有库存,减少它,然后插入订单。那相当于“把门关上”直到你完成关键步骤。
隔离级别控制了并发事务之间允许出现多少怪异重叠。通常是安全性与速度的权衡:
- Read Committed(默认):速度快,但你可能在语句之间看到别的事务提交的更改。
- Repeatable Read:事务看到的是稳定的快照,适合一致性读取,但可能需要更多重试。
- Serializable:最强的安全性,Postgres 可能会中止某个事务以保证结果像顺序执行一样。
把锁保持得短。如果事务在你等待外部 API 调用或用户操作时一直打开,会造成长时间等待和超时。最好有明确的失败路径:设置锁超时,捕获错误,然后返回“请重试”而不是让请求一直挂着。
如果必须在数据库外做工作(比如扣款),把工作拆分:快速预留、提交、然后做慢操作,最后再用另一个短事务完成收尾工作。
不会造成重复的重试
在基于 Postgres 的应用中,重试是很常见的。即使代码是正确的,请求也可能失败:死锁、语句超时、短暂的网络中断,或在更高隔离级别下的序列化错误。如果你只是简单重跑同一个处理器,你可能会创建第二个订单、重复扣款或插入重复的“事件”行。
解决办法是幂等性:相同输入下的操作在重复运行时应该是安全的。数据库应该能识别“这是同一个请求”,并返回一致的结果。
实用模式是给每个多步工作流附加一个幂等键(通常是客户端生成的 request_id),把它存到主记录上,并对该键加唯一约束。
例如:在结账时,用户点击付款时生成 request_id,然后带着该 request_id 插入订单。如果重试发生,第二次尝试会触发唯一约束,你就返回已存在的订单而不是创建新订单。
通常需要注意的点:
- 对 (request_id) 或 (user_id, request_id) 使用唯一约束来阻止重复。
- 当触发约束冲突时,查询并返回已存在的行以得到相同结果。
- 让副作用也遵循相同规则:每个订单只有一个 payment intent,每个订单只有一次“订单确认”事件。
- 记录 request_id 以便支持团队追踪发生了什么。
把重试循环放到事务之外。每次尝试都应该开启一个新的事务并从头重新运行整个工作单元。在失败的事务里重试没有意义,因为 Postgres 会把事务标记为已中止。
举个小例子:应用尝试创建订单并预留库存,但在 COMMIT 之后超时返回。客户端重试。使用幂等键时,第二次请求会返回已创建的订单并跳过第二次预留,从而避免重复操作。
用数据库来强制规则,而不仅仅依赖代码
事务把多步工作流绑在一起,但并不会自动保证数据正确。一个强有力的方法是让数据库把“错误”状态变得困难或不可能,即使应用代码中有漏洞也能防止坏结果。
先从基本的安全护栏做起。外键确保引用真实存在(订单行不能指向不存在的订单)。NOT NULL 阻止半填充的行。CHECK 约束捕获不合理的值(例如 quantity > 0、total_cents >= 0)。这些规则在每次写入时都会执行,不论哪个服务或脚本在操作数据库。
对于更长的工作流,显式建模状态变化。不要用很多布尔标志,使用一个状态列(pending、paid、shipped、canceled)并只允许合法的迁移。你可以用约束或触发器来强制,数据库会拒绝非法跳转,比如 shipped -> pending。
唯一性也是另一种正确性保障。在会破坏工作流的地方添加唯一约束:order_number、invoice_number,或用于重试的幂等键。这样当应用重试同一请求时,Postgres 会阻止第二次插入,你就可以安全地返回“已处理”而不是再造一笔订单。
当你需要可追溯性时,把它显式存储。一张审计表(或历史表)记录谁在什么时候改了什么,会把“神秘更新”变成可查询的事实,有助于事件调查。
导致部分写入的常见错误
大多数部分写入并不是因为“坏 SQL”。它们来自那些容易导致只提交一半故事的工作流决策。
真正应用里常见的陷阱
- 在事务打开时做慢的外部工作。把支付提供商、发送邮件或上传文件等调用放在事务里会导致锁被长期持有。如果 API 很慢或超时,其他用户会在你开启的事务后排队。
- 在事务外读取,然后晚些再写。例子:你读取用户余额并显示,然后基于那个旧值去扣,期间其他会话可能已更改了余额。
- 捕获错误但仍然提交东西。常见模式是“try 步骤 1,try 步骤 2,记录错误,但仍返回成功”。如果代码在失败后仍执行到 COMMIT,你就是故意让数据库不一致。
- 在不同代码路径中以不同顺序更新表。如果一个请求先更新
accounts再更新orders,另一个请求反过来,你在高负载下遇到死锁的概率会升高。 - 把事务开得太久。长事务会阻塞写入、延迟 vacuum 清理,并造成令人困惑的超时。
一个具体例子:在结账中,你先预留库存、创建订单,然后扣卡。如果你在同一事务内扣卡,可能会在等待网络时持有库存锁。如果扣款成功但你的事务后来回滚,你就扣了客户的钱却没有订单。
更安全的模式是:把事务聚焦在数据库状态(预留库存、创建订单、记录待支付)上,提交后再调用外部 API,然后用另一个短事务把结果写回。许多团队用 pending 状态加后台作业实现这点。
全有或全无操作的快速检查清单
当一个工作流有多步(插入、更新、扣款、发送),目标很简单:要么所有东西都记录下来,要么什么都不记录。
事务边界
把所有必需的数据库写入放在一个事务内。如果某步失败,就回滚并把数据恢复到原来状态。
把成功条件写明。例如:“订单已创建、库存已预留、付款状态已记录。”除了这些之外都是失败路径,必须中止事务。
- 所有必需写入都在一个
BEGIN ... COMMIT块内。 - 数据库里有一个明确的完成状态(而不仅仅是在应用内存中)。
- 任何错误都会导致
ROLLBACK,调用方得到清晰的失败结果。
安全护栏(让重试无害)
假设同一请求可能会被重试。数据库应该帮助你强制“只做一次”的规则。
- 用唯一约束支持只做一次的动作(每个订单只有一条支付记录,或每件商品每个订单只有一次预留)。
- 让重试安全且可重复(相同输入产生相同最终状态,而不是重复记录)。
保持事务短小
把事务内的工作量减到最少,避免在持锁时等待网络调用。
- 把事务保持短小,并设置超时以免挂起。
- 把慢工作(如调用支付提供商)放到事务外,然后在新的短事务里记录结果。
观察失败
如果你看不到出错点,就只能不停猜测。
- 每次失败时记录工作流步骤和 request id。
- 监控回滚率和锁超时,尽早发现部分写入风险。
示例:在失败情况下仍保持一致的结账流程
结账包含多个步骤,这些步骤应该一起移动:创建订单、预留库存、记录支付尝试,然后标记订单状态。
假设用户点击购买 1 件商品。
一个安全的流程(数据库操作作为一个单元)
在一个事务中只做数据库变更:
- 插入一行
orders,状态为pending_payment。 - 预留库存(例如,减少
inventory.available或插入一条reservations)。 - 插入一条带有客户端提供的
idempotency_key(唯一)的payment_intents记录。 - 插入一条
outbox记录,例如 “order_created”。
如果任一语句失败(缺货、约束错误、崩溃),Postgres 会回滚整个事务。你不会得到只有订单没有预留,或只有预留没有订单的情况。
如果支付半途而废怎么办?
支付提供商在数据库之外,所以把它当作独立步骤。
如果在你提交之前调用提供商失败,终止事务并不写任何东西。如果在你提交之后提供商调用失败,用一个新的事务把 payment attempt 标为失败、释放预留并把订单状态设为已取消。
重试而不创建第二个订单
让客户端为每次结账尝试发送 idempotency_key。用唯一索引强制它,比如 payment_intents(idempotency_key)(或如果你更喜欢也可以放在 orders 上)。重试时,你的代码查找现有行并继续,而不是插入新订单。
邮件与通知
不要在事务里发送邮件。在同一事务里写入 outbox 记录,然后让后台 worker 在提交后发送邮件。这样你就不会为被回滚的订单发送邮件。
接下来的步骤:这周把它应用到一个工作流上
选一个涉及多张表的工作流来做:注册并入队欢迎邮件、结账并预留库存、发票并记账,或创建项目并生成默认设置。
先写步骤,然后写出必须始终为真的规则(你的不变量)。例如:“订单要么是已完全支付并已预留,要么既未支付也未预留。绝不允许半预留状态。”把这些规则变成一个全有或全无的单元。
一个简单计划:
- 列出确切的 SQL 操作顺序(读取、插入、更新、删除)。
- 先添加缺失的数据库约束(唯一键、外键、检查约束)。
- 为请求添加幂等键,防止重试创造重复。
- 把步骤包在一个事务里,并把成功点写明(只有所有检查通过才提交)。
- 决定什么是安全的重试(相同的幂等键、相同的结果)。
然后有针对性地测试糟糕情况:模拟在第 2 步后崩溃、在提交前超时、以及 UI 的重复提交。目标是枯燥的结果:没有孤立行,没有重复扣款,没有永远挂起的状态。
如果你在快速原型阶段,把工作流先在一个以规划为先的工具里草绘比直接生成处理器和 schema 更有帮助。例如,Koder.ai (koder.ai) 有一个 Planning Mode,并支持快照与回滚,这在你迭代事务边界和约束时会很有帮助。
本周把它应用到一个工作流上。第二个会快很多。
常见问题
何时应该使用 Postgres 事务?
当一次用户操作会修改多行或多张必须保持一致的表时,就应使用事务。常见场景包括结账、账户转账、注册时分配角色,以及库存预留。
COMMIT 在 Postgres 中有什么作用?
提交会让事务中的所有更改永久生效,并对其他数据库会话可见。只有在所有必要检查和写入都成功后才提交。
执行 ROLLBACK 时会发生什么?
回滚会取消事务并删除其中尚未提交的更改。任何必要步骤失败时,代码都应回滚,然后返回调用方可以处理的错误。
事务中的所有查询都需要使用同一个连接吗?
从 BEGIN 到 COMMIT 或 ROLLBACK 期间,此工作流的数据库读取和写入应始终使用同一个数据库连接。不同连接会创建不同的事务。
应该在事务内调用支付或邮件 API 吗?
不需要。事务能将数据库更改保持在一起,但无法撤销银行卡扣款、电子邮件或第三方 API 调用。先提交数据库状态,记录发件箱事件或待处理状态,再单独执行外部操作。
如何防止两名用户购买最后一件商品?
在检查和更新共享值(如可用库存)之前锁定该行。例如,使用 FOR UPDATE 查询库存行,确认库存后扣减数量,并在提交前创建订单。
重试如何避免重复订单?
为每个请求设置稳定的幂等键,并用唯一约束强制执行。重试触发该约束时,加载并返回已有结果,而不是再插入一条订单或支付记录。
为什么重试必须启动新事务?
应在一个新的事务中从头重试。语句出错后,Postgres 会将当前事务标记为已中止,因此在回滚前无法安全地继续执行。
哪些数据库约束有助于防止数据不一致?
使用外键确保引用有效,使用 NOT NULL 标记必填字段,使用 CHECK 约束限制有效值,并对只能存在一条的记录使用唯一约束。即使应用代码有缺陷,这些规则也能保护数据。
为什么事务应该保持简短?
事务应只处理快速的数据库操作。长事务会持有锁、增加等待时间,还可能造成死锁或超时,尤其是在等待网络调用时。