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

大多数真实功能不是一次数据库更新就完事的。它们通常是一小段链:插入一行、更新余额、标记状态、写审计记录,也许还要入队一个作业。部分写入就是只有其中某些步骤写进了数据库。
这通常出现在链路被打断的时候:服务器错误、应用和 Postgres 之间的超时、在第 2 步后崩溃,或者重试重跑了第 1 步。每条语句本身都没问题。问题在于工作流中途停止时出现的差异。
你通常可以快速发现这些问题:
一个具体例子:升级套餐需要更新客户的 plan、添加一条支付记录、并增加可用额度。如果应用在保存支付记录后但在增加额度前崩溃,客服会看到一张表里显示“已付款”,另一张表却显示“无额度”。如果客户端重试,你甚至可能记录了两次支付。
目标很简单:把整个工作流当成一个开关。要么所有步骤都成功,要么都不生效,这样就不会存下半截的工作。
事务是数据库告诉你:把这些步骤视为一个工作单元。要么所有更改都发生,要么都不发生。每当你的工作流需要多个更新(比如创建一行、更新余额、写审计记录)时,这点就很重要。
想象在两个账户之间转账。你必须从账户 A 扣款并给账户 B 记入。如果应用在第一步之后崩溃,你不希望系统只记住了扣款这一半动作。
当你**提交(commit)**时,你告诉 Postgres:保留我在这个事务里做的所有事情。所有更改变为永久并对其他会话可见。
当你**回滚(rollback)**时,你告诉 Postgres:忘掉我在这个事务里做的所有事情。Postgres 会把这些更改撤销,就像事务从未发生过一样。
在事务内部,Postgres 保证在你提交前不会把半成品暴露给其他会话。如果某些操作失败并且你回滚,数据库会清理该事务的写入。
事务并不能修复糟糕的工作流设计。如果你扣错了金额、用了错误的用户 ID、或跳过了必要检查,Postgres 会忠实地提交错误的结果。事务也不会自动阻止所有业务级别的冲突(比如超卖库存),除非你配合了合适的约束、锁或隔离级别。
任何时候,当你为了完成一个现实世界的动作需要更新多张表(或多行)时,这就是事务的候选场景。核心思想不变:要么一切完成,要么什么都不做。
订单流程是经典案例。你可能会创建订单行、预留库存、扣款,然后把订单标为已支付。如果支付成功但状态更新失败,你就会有钱被捕获但订单看起来仍是未支付。如果订单行创建了但库存没有预留,你就可能卖出实际并不存在的商品。
用户入职同样会悄然出问题。创建用户、插入 profile、分配角色、记录应发送的欢迎邮件,这些是一个逻辑动作。如果不分组,你可能得到可以登录但没有权限的用户,或者存在没有用户的 profile。
后台办公操作常常需要严格的“凭证 + 状态变更”行为。批准请求、写审计条目、更新余额这些操作应该一起成功。如果余额变了但审计日志缺失,你就会丢失谁在什么时间为什么改变了数据的证据。
后台任务也受益于事务,特别是当你处理一个工作项包含多步时:先认领任务以避免两个 worker 同时处理,应用业务更新,记录结果以便上报与重试,然后把项标为完成(或以失败原因为失败)。如果这些步骤分散开,重试和并发会把事情弄得一团糟。
当你把多步功能当成一堆独立更新时,它们就会出问题。在打开数据库客户端之前,把工作流写成一个简短的故事,并给出一个清晰的完成标准:对用户来说“完成”到底是什么意思?
先用普通语言列出步骤,然后定义一个单一的成功条件。例如:“订单已创建、库存已预留、用户能看到一个订单确认号。”哪怕只有部分表被更新,也不能算成功。
接下来,把数据库工作和外部工作划清界限。数据库步骤是你可以用事务保护的。像银行卡扣款、发送邮件或调用第三方 API 这类外部调用会以慢而不可预测的方式失败,而且通常无法回滚。
一个简单的规划方法:把步骤分成(1)必须是全有或全无的、(2)可以在提交后发生的。
在事务内只保留那些必须保持一致的步骤:
把副作用移到外面。例如,先提交订单,然后根据 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 选中库存行,验证有库存,减少它,然后插入订单。那相当于“把门关上”直到你完成关键步骤。
隔离级别控制了并发事务之间允许出现多少怪异重叠。通常是安全性与速度的权衡:
把锁保持得短。如果事务在你等待外部 API 调用或用户操作时一直打开,会造成长时间等待和超时。最好有明确的失败路径:设置锁超时,捕获错误,然后返回“请重试”而不是让请求一直挂着。
如果必须在数据库外做工作(比如扣款),把工作拆分:快速预留、提交、然后做慢操作,最后再用另一个短事务完成收尾工作。
在基于 Postgres 的应用中,重试是很常见的。即使代码是正确的,请求也可能失败:死锁、语句超时、短暂的网络中断,或在更高隔离级别下的序列化错误。如果你只是简单重跑同一个处理器,你可能会创建第二个订单、重复扣款或插入重复的“事件”行。
解决办法是幂等性:相同输入下的操作在重复运行时应该是安全的。数据库应该能识别“这是同一个请求”,并返回一致的结果。
实用模式是给每个多步工作流附加一个幂等键(通常是客户端生成的 request_id),把它存到主记录上,并对该键加唯一约束。
例如:在结账时,用户点击付款时生成 request_id,然后带着该 request_id 插入订单。如果重试发生,第二次尝试会触发唯一约束,你就返回已存在的订单而不是创建新订单。
通常需要注意的点:
把重试循环放到事务之外。每次尝试都应该开启一个新的事务并从头重新运行整个工作单元。在失败的事务里重试没有意义,因为 Postgres 会把事务标记为已中止。
举个小例子:应用尝试创建订单并预留库存,但在 COMMIT 之后超时返回。客户端重试。使用幂等键时,第二次请求会返回已创建的订单并跳过第二次预留,从而避免重复操作。
事务把多步工作流绑在一起,但并不会自动保证数据正确。一个强有力的方法是让数据库把“错误”状态变得困难或不可能,即使应用代码中有漏洞也能防止坏结果。
先从基本的安全护栏做起。外键确保引用真实存在(订单行不能指向不存在的订单)。NOT NULL 阻止半填充的行。CHECK 约束捕获不合理的值(例如 quantity > 0、total_cents >= 0)。这些规则在每次写入时都会执行,不论哪个服务或脚本在操作数据库。
对于更长的工作流,显式建模状态变化。不要用很多布尔标志,使用一个状态列(pending、paid、shipped、canceled)并只允许合法的迁移。你可以用约束或触发器来强制,数据库会拒绝非法跳转,比如 shipped -> pending。
唯一性也是另一种正确性保障。在会破坏工作流的地方添加唯一约束:order_number、invoice_number,或用于重试的幂等键。这样当应用重试同一请求时,Postgres 会阻止第二次插入,你就可以安全地返回“已处理”而不是再造一笔订单。
当你需要可追溯性时,把它显式存储。一张审计表(或历史表)记录谁在什么时候改了什么,会把“神秘更新”变成可查询的事实,有助于事件调查。
大多数部分写入并不是因为“坏 SQL”。它们来自那些容易导致只提交一半故事的工作流决策。
accounts 再更新 orders,另一个请求反过来,你在高负载下遇到死锁的概率会升高。一个具体例子:在结账中,你先预留库存、创建订单,然后扣卡。如果你在同一事务内扣卡,可能会在等待网络时持有库存锁。如果扣款成功但你的事务后来回滚,你就扣了客户的钱却没有订单。
更安全的模式是:把事务聚焦在数据库状态(预留库存、创建订单、记录待支付)上,提交后再调用外部 API,然后用另一个短事务把结果写回。许多团队用 pending 状态加后台作业实现这点。
当一个工作流有多步(插入、更新、扣款、发送),目标很简单:要么所有东西都记录下来,要么什么都不记录。
把所有必需的数据库写入放在一个事务内。如果某步失败,就回滚并把数据恢复到原来状态。
把成功条件写明。例如:“订单已创建、库存已预留、付款状态已记录。”除了这些之外都是失败路径,必须中止事务。
BEGIN ... COMMIT 块内。ROLLBACK,调用方得到清晰的失败结果。假设同一请求可能会被重试。数据库应该帮助你强制“只做一次”的规则。
把事务内的工作量减到最少,避免在持锁时等待网络调用。
如果你看不到出错点,就只能不停猜测。
结账包含多个步骤,这些步骤应该一起移动:创建订单、预留库存、记录支付尝试,然后标记订单状态。
假设用户点击购买 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 在提交后发送邮件。这样你就不会为被回滚的订单发送邮件。
选一个涉及多张表的工作流来做:注册并入队欢迎邮件、结账并预留库存、发票并记账,或创建项目并生成默认设置。
先写步骤,然后写出必须始终为真的规则(你的不变量)。例如:“订单要么是已完全支付并已预留,要么既未支付也未预留。绝不允许半预留状态。”把这些规则变成一个全有或全无的单元。
一个简单计划:
然后有针对性地测试糟糕情况:模拟在第 2 步后崩溃、在提交前超时、以及 UI 的重复提交。目标是枯燥的结果:没有孤立行,没有重复扣款,没有永远挂起的状态。
如果你在快速原型阶段,把工作流先在一个以规划为先的工具里草绘比直接生成处理器和 schema 更有帮助。例如,Koder.ai (koder.ai) 有一个 Planning Mode,并支持快照与回滚,这在你迭代事务边界和约束时会很有帮助。
本周把它应用到一个工作流上。第二个会快很多。