2025年9月07日·阅读约1分钟

使用 PostgreSQL 建议锁实现并发安全的工作流

学习使用 PostgreSQL 建议锁,防止审批、计费和调度器中的重复处理;含实用模式、SQL 片段和简明校验建议。

使用 PostgreSQL 建议锁实现并发安全的工作流

真正的问题:两个进程做了相同的事

双重处理是指同一项工作被处理了两次,因为两个不同的执行者都认为自己负责。在真实应用中,它表现为客户被重复扣款、一次审批被执行两次,或“发票已准备好”的邮件发出两次。一切在测试中看起来正常,但在真实流量下会出问题。

它通常发生在时序紧张且可能有多个执行者同时动作时:

两个 worker 同时拿到同一个任务。一次重试触发因为网络调用慢,但第一次尝试仍在运行。用户因为界面卡住而双击“批准”。在部署后或时钟漂移时两个调度器重叠。移动应用在超时后重发也可能把一次点击变成两个请求。

痛点在于每个执行者单独看都表现“合理”。漏洞在它们之间的缝隙:没有一个知道另一个已经在处理同一条记录。

目标很简单:对于任何给定项(订单、审批请求、发票),在同一时间只允许一个执行者进行关键工作。其他人要么短暂等待,要么回退并重试。

PostgreSQL 的建议锁可以帮忙。它们提供了一种轻量的方法,用你已信任的数据库来表明“我正在处理 X 项”。

不过要设定期望:锁不是完整的队列系统。它不会为你调度任务、保证顺序或存储消息。它只是围住那段绝对不能重复运行的工作。

什么是(以及不是什么)PostgreSQL 建议锁

PostgreSQL 建议锁是一种保证同一时间只有一个 worker 做某件事的方法。你挑一个锁键(比如“invoice 123”)、向数据库请求锁、做事,然后释放锁。

“建议(advisory)”这个词很重要。Postgres 并不知道你的键具体含义,也不会自动保护任何东西。它只记录一个事实:这个键被锁了还是没有。你的代码必须对键格式达成一致,并且在执行有风险的部分之前获取锁。

把建议锁和行锁比较也有帮助。行锁(比如 SELECT ... FOR UPDATE)保护实际的表行。当工作映射到单行时它们很合适。建议锁保护你选择的键,适用于工作流跨多表、调用外部服务,或在某行还不存在时就要开始的情形。

当你需要下列场景时,建议锁很有用:

  • 对单个实体的一次性操作(每个请求一次批准、每张发票只扣一次)
  • 在不增加额外锁服务的情况下跨多台应用服务器协调
  • 保护比单行更新更大的工作流步骤

它们并不能替代其它安全手段。它们不会让操作本身变为幂等,不会强制执行业务规则,也无法阻止忘记加锁的代码路径造成重复。

因为不需要改表结构或额外基础设施,人们常称它们“轻量”。在很多情况下,只需在关键区段加一次锁调用就能修复双重处理问题,其余设计保持不变。

你实际会用到的锁类型

人们说“PostgreSQL 建议锁”时,通常指一小组函数。选择合适的函数会影响错误、超时和重试时的行为。

会话级 与 事务级 锁

会话级锁(pg_advisory_lock)的生命周期是数据库连接的生命周期。对长时间运行的 worker 很方便,但如果应用崩溃并导致连接池里的连接挂起,锁可能会一直保留。

事务级锁(pg_advisory_xact_lock)绑定到当前事务。当你提交或回滚时,PostgreSQL 会自动释放它。对于大多数请求-响应类工作流(审批、计费点击、管理操作),这是更安全的默认,因为不容易忘记释放。

阻塞式 与 尝试锁

阻塞式调用会等到锁可用为止。简单,但如果另一个会话持有锁,会让 web 请求感觉卡住。

尝试锁会立即返回:

  • pg_try_advisory_lock(会话级)
  • pg_try_advisory_xact_lock(事务级)

对于 UI 操作,尝试锁通常更好。如果锁已被占用,你可以返回一个清晰的“已在处理”消息并提示用户重试。

共享锁 与 排他锁

排他锁是“一个接一个”。共享锁允许多个持有者,但会阻塞排他锁。大多数双重处理问题使用排他锁。共享锁适合大量读取者可以并行,但某个罕见的写入者必须独占时使用。

锁如何释放

释放方式取决于类型:

  • 会话锁:断开连接时释放,或显式调用 pg_advisory_unlock
  • 事务锁:事务结束时自动释放。

如何选择合适的锁键

建议锁只有在每个 worker 尝试对同一件事使用完全相同的键时才有效。如果一个代码路径锁的是“invoice 123”,另一个锁的是“customer 45”,你仍然会遭遇重复。

先把你要保护的“事”明确命名。把它具体化:一张发票、一个审批请求、一次计划任务的运行或一个客户的月度计费周期。这个选择决定了你允许的并发量。

选择与风险相匹配的范围

大多数团队最终采用下列之一:

  • 每条记录:对审批和发票最安全(按 invoice_idrequest_id 加锁)
  • 每个客户/账户:当操作需要按客户串行时有用(计费、信用变动)
  • 每个工作流步骤:当不同步骤可以并行运行,但每个步骤必须串行时

把范围当作产品决策,而不是数据库细节。“按记录”能防止双击导致重复扣款。“按客户”能防止两个后台作业生成重叠报表。

选择稳定的键策略

通常有两种选择:两个 32 位整数(常用作命名空间 + id),或一个 64 位整数(bigint),有时通过对字符串 ID 做哈希得到。

两个整数的键容易标准化:为每个工作流选一个固定命名空间号(例如审批 vs 计费),第二个值用记录 ID。

当标识符是 UUID 时,哈希很方便,但须接受少量碰撞风险并在所有地方保持一致。

无论选择什么,把格式写下来并集中管理。两处“几乎相同的键”是重新引入重复的常见原因。

逐步示例:保证一次只处理一个项的安全模式

对你的工作流状态建模
使用 Data Designer 映射表结构并保持状态字段清晰。
Design data

一个好的建议锁工作流很简单:先锁、再校验、再执行、写记录、提交。锁本身不是业务规则,它是当两个 worker 同时碰到同一记录时让规则可靠的护栏。

一个实用模式:

  1. 在需要原子性时打开事务。
  2. 获取针对具体工作单元的锁。优先事务级锁(pg_advisory_xact_lock),这样它会自动释放。
  3. 在数据库中重新检查状态。别假设自己是第一个。确认记录仍然可处理。
  4. 执行业务并写入持久化的“已完成”标记(状态更新、账本条目、审计行)。
  5. 提交并释放锁。如果你使用了会话级锁,在把连接返回连接池前显式解锁。

举例:两台应用服务器在同一秒内接到“批准发票 #123”。它们都开始,但只有一台能获得针对 123 的锁。获胜者检查发票仍是 pending,将其标为 approved,写入审计/支付记录并提交。第二台要么快速失败(尝试锁),要么等待到第一个完成后再拿到锁,然后看到状态已经是 approved 并退出,不会创建重复项。这样既避免了双重处理,也保持了 UI 的响应性。

建议锁适用场景:审批、计费、调度器

当规则很明确:针对某个具体对象,同一时间只有一个进程能做“关键”工作时,建议锁最合适。你保留现有数据库和应用代码,只在关键处加一个小门槛,使竞态条件难以触发。

审批

审批是经典的并发陷阱。两个审核者(或同一个人双击)可能在毫秒级别内同时点击“批准”。如果用请求 ID 作为锁键,只有一个事务会执行状态变更。其他人很快会得知结果并显示“已批准”或“已拒绝”之类清晰的提示。

这在客户门户和管理员面板中很常见,很多人会同时查看同一个队列。

计费

计费通常需要更严格的规则:每张发票只允许一次支付尝试,即使出现重试。网络超时可能使用户再次点击支付,或者后台重试在第一次仍在进行时触发。

用发票 ID 作为锁键可以确保同时只有一条路径在与支付提供商通信。第二次尝试可以返回“支付进行中”或读取最新支付状态,从而避免重复工作和重复扣款风险。

调度与后台工作者

在多实例部署中,调度器可能意外并行运行相同窗口。用作业名加时间窗口作为键(例如 daily-settlement:2026-01-29)可以确保只有一个实例运行它。

同样方法适用于从表中拉任务的 worker:对项 ID 加锁,只有一个 worker 能处理它。

常见的锁键有单个审批请求 ID、单张发票 ID、作业名加时间窗口、按客户加锁以保证“一个导出一次”,或用于重试的唯一幂等键。

一个真实示例:在门户中阻止重复审批

标准化你的锁键
在后端创建共享的锁键助手,让所有工作流使用相同规则。
Build backend

设想一个门户中的审批请求:采购订单在等待,两个经理在同一秒内都点击了批准。如果没有保护,两次请求都可能读到“pending”,并都写入“approved”,造成重复审计记录、重复通知或触发重复的下游工作。

PostgreSQL 建议锁为你提供了一个直接的方式:让每个审批一次只被一个事务处理。

流程

当 API 收到批准动作时,先基于审批 id 获取锁(不同审批之间仍可并行处理)。

一个常见模式是:对 approval_id 加锁、读取当前状态、更新状态、然后写审计记录,所有操作放在同一个事务中。

BEGIN;

-- One-at-a-time per approval_id
SELECT pg_try_advisory_xact_lock($1) AS got_lock;  -- $1 = approval_id

-- If got_lock = false, return "someone else is approving, try again".

SELECT status FROM approvals WHERE id = $1 FOR UPDATE;

-- If status != 'pending', return "already processed".

UPDATE approvals
SET status = 'approved', approved_by = $2, approved_at = now()
WHERE id = $1;

INSERT INTO approval_audit(approval_id, actor_id, action, created_at)
VALUES ($1, $2, 'approved', now());

COMMIT;

注:上面为代码块,内部内容不应翻译或改动。

第二次点击的体验

第二个请求要么拿不到锁(所以会快速返回“正在被处理,请稍后再试”),要么在第一个完成后拿到锁并发现状态已被处理,从而退出而不做改变。无论哪种情况,都能避免双重处理同时保持 UI 响应。

为调试记录足够信息:请求 ID、审批 ID 与计算出的锁键、执行者 ID、结果(lock_busy、already_approved、approved_ok)和时间信息。

在不让应用卡住的情况下处理等待、超时与重试

优雅处理锁忙情况
返回像“正在处理”这样的快速消息,而不是让请求挂起。
Improve UX

等待锁听起来无害,直到它变成旋转按钮、卡住的 worker 或永远清不掉的积压。当无法获取锁时,在有人的地方快速失败,在安全可等待的地方等待。

针对用户操作:尝试锁并清晰响应

当用户点击批准或支付时,不要把他们的请求阻塞几秒。使用尝试锁让应用能立刻应答。

实用方法是:尝试获取锁,如果失败,返回明确的“忙,请重试”响应(或刷新项的状态)。这减少超时并抑制重复点击。

保持被锁区段短小:验证状态、应用状态变更、提交。

针对后台作业:阻塞可以,但要设上限

对于调度器和 worker,阻塞通常可以接受,因为没有人实时等待。但仍需限制,否则一个慢任务会阻塞整个集群。

使用超时让 worker 能放弃并继续处理别的任务:

SET lock_timeout = '2s';
SET statement_timeout = '30s';
SELECT pg_advisory_lock(123456);

还要为作业本身设置最大预期运行时。如果计费通常在 10 秒内完成,把 2 分钟作为事故阈值。记录开始时间、作业 ID 和锁持有时间。如果作业运行超时且任务运行器支持取消,请取消任务以结束会话并释放锁。

有计划地进行重试。当无法获取锁时决定下一步:带回退与随机抖动的短期重试、对本周期跳过尽力而为的工作,或当反复失败时把该项标记为争用以便人工介入。

常见错误会导致卡住的锁或重复执行

最常见的惊讶来自会话级锁未被释放。连接池会保持连接打开,所以会话锁如果忘记解锁,可能一直存在直到连接被回收。其他 worker 要么等待要么失败,且原因不易发现。

另一个重复来源是加了锁但不重新检查状态。锁只能保证同一时间只有一个 worker 进入临界区。它并不保证记录仍然可处理。务必在同一事务内重新检查(例如确认仍为 pending)再进行变更。

锁键也会绊倒团队。如果一个服务按 order_id 加锁,而另一个对同一真实资源计算了不同的键,两者之间就没有协调,两个路径可能同时运行,产生虚假的安全感。

长时间持锁通常是自找的。如果在持锁期间做慢的网络调用(支付提供商、邮件/SMS、webhook),短小的护栏会变成瓶颈。把被锁区段聚焦在快速的数据库工作:验证状态、写入新状态、记录接下来要做的事。然后在事务提交后触发副作用。

最后,建议锁不能替代幂等或数据库约束。把它当成交通信号,而不是证明系统正确的证据。在合适的地方使用唯一约束,并对外部调用使用幂等键。

发布前的快速检查清单

防止计划任务重叠运行
通过按作业名和时间窗口加锁,使多实例作业安全运行。
Create app

把建议锁当成一个小合同:团队每个人都应知道锁的含义、它保护什么,以及在持锁期间允许发生什么。

一个能捕捉大多数问题的简短清单:

  • 每个资源一个明确的锁键,写下来并在所有地方复用
  • 在执行任何不可逆操作前先获取锁(支付、邮件、外部 API 调用)
  • 在持锁后且写入前重新检查状态
  • 把被锁区段保持短小且可测量(记录锁等待和执行时间)
  • 为每条路径决定“锁忙”意味着什么(UI 提示、回退重试、跳过)

接下来的步骤:应用模式并保持可维护性

先选一个重复带来最大伤害的地方开始。好初始目标是会花钱或永久改变状态的动作,比如“扣发票款”或“批准请求”。仅用建议锁包裹那段关键区段,等你可信任行为后再扩展到相邻步骤。

尽早加入基础可观测性。记录当 worker 无法获取锁时的情况,以及持锁工作所需时间。如果锁等待激增,通常意味着关键区段太大或其中藏着慢查询。

锁在建立在数据安全之上时效果最好,而不是替代它。保持清晰的状态字段(pending、processing、done、failed),在可能的地方用约束支撑它们。如果重试发生在最糟糕的时刻,唯一约束或幂等键可以作为第二道防线。

如果你在用 AppMaster (appmaster.io) 构建工作流,可以用相同的模式:把关键状态变更放到一个事务内,并在“完成”步骤前添加一小步 SQL 来获取事务级建议锁(pg_advisory_xact_lock)。

当你真正需要队列特性(优先级、延迟任务、死信处理)、遇到严重争用需要更智能的并行策略、必须在无共享 Postgres 的数据库间协调,或需要更严格的隔离规则时,才考虑替换成队列系统。目标是实现平淡可靠:把模式做小、统一、在日志中可见,并用约束作后盾。

常见问题

什么时候应该使用 PostgreSQL 建议锁,而不是只相信应用逻辑?

当你需要对某个具体工作单元保证“每次只有一个执行者”时(比如审批请求、扣款或运行一个调度窗口),就应使用建议锁。当多个应用实例可能同时处理同一项且你不想额外引入独立的锁服务时,建议锁特别有用。

建议锁与 SELECT ... FOR UPDATE 行锁有什么不同?

行级锁会保护你选中的实际表行,适合当整个操作与单行更新完全对应时。建议锁保护的是你选定的一个键,因此即使工作流涉及多表、调用外部服务,或在最终行存在之前就开始,建议锁仍然适用。

我应该使用事务级还是会话级建议锁?

对于请求/响应类动作,默认使用 pg_advisory_xact_lock(事务级),因为它会在提交或回滚时自动释放。只有在你确实需要锁存活超过单个事务,并且能保证在把连接返回连接池前总会解锁时,才使用 pg_advisory_lock(会话级)。

等待锁与使用尝试锁哪个更好?

对于用户界面驱动的操作,优先使用尝试锁(比如 pg_try_advisory_xact_lock),这样请求可以快速失败并返回清晰的“正在处理”响应。对于后台工作者,阻塞式获取锁可以接受,但要用 lock_timeout 限制等待时间,避免单个卡住的任务阻塞整个队列。

我应该按记录 ID、客户 ID 还是其它方式加锁?

把需要“不能重复运行”的最小粒度作为锁对象,通常是一条记录(比如一张发票或一个审批请求)。如果你把范围定得太宽(例如按客户),吞吐量会下降;如果定得太窄或各路径使用不同的键,仍可能出现重复。

如何选择锁键,确保所有服务使用完全相同的键?

选定一个稳定的键格式并在所有可能执行关键操作的地方统一使用。常见做法是用两个整数:一个固定的命名空间表示工作流类型,加上实体 ID,这样不同工作流不会互相阻塞,但相同工作流会正确协调。

建议锁能替代幂等检查或唯一约束吗?

不能。锁只防止并发执行;它并不证明操作是可重复安全的。你仍需在事务内重新检查状态(例如确认项仍是 pending),并在适合的地方依赖唯一约束或幂等键来作为第二道防线。

我在被锁区段内应该做什么以避免拖慢整体?

把被锁住的代码段保持短小且以数据库操作为主:获取锁、重新确认可用性、写入新状态并提交。将慢的外部调用(支付、邮件、Webhook)放到提交后或使用 outbox 模式,以免在持锁期间发生网络延迟。

为什么有时建议锁看起来“卡住”了,即使请求已经结束?

最常见的原因是会话级锁被连接池中的连接持有,而代码路径没有正确解锁,导致连接归还池后锁仍存在。优先使用事务级锁;如果必须使用会话级,确保在把连接返回池前可靠地调用 pg_advisory_unlock

我应该记录或监控哪些信息来判断建议锁是否有效?

记录实体 ID 与计算得到的锁键、是否获取到锁、获取锁花费的时间、事务运行时长,以及结果状态如 lock_busyalready_processedprocessed_ok。这些日志可以帮助区分是争用(contention)还是实际重复处理问题。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧