1 分钟

Postgres 事务用于多步工作流:实用模式

学习 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 错误码。避免记录完整载荷、卡数据或个人详情。

并发基础:不用术语也能懂的锁与隔离

把副作用移出事务
建模 outbox 事件和后台任务,让副作用在提交后发生。

并发只是两件事同时发生。想象两位顾客同时试图买最后一张演唱会票。两个页面都显示“仅剩 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 约束限制有效值,并对只能存在一条的记录使用唯一约束。即使应用代码有缺陷,这些规则也能保护数据。

为什么事务应该保持简短?

事务应只处理快速的数据库操作。长事务会持有锁、增加等待时间,还可能造成死锁或超时,尤其是在等待网络调用时。

Related posts