Zulip 数据库并发控制实战:深入理解 select_for_update() 与行级锁的正确用法
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
Zulip 是一个高性能的开源团队聊天服务,其服务器端大量使用 PostgreSQL 行级锁来保证多用户、多进程并发场景下"读后写"操作的一致性。本篇技术指南以 Zulip 仓库中的数据库并发设计文档为骨架,结合 select_for_update() 在 Django 中的官方语义 与 Zulip 真实源码,系统讲解何时加锁、加多强的锁、如何避免死锁,以及 Zulip 如何用 semgrep 强制约束每一处锁调用。读完本文,你将掌握在 Django + PostgreSQL 项目中安全使用行锁的完整方法论,并能看懂 Zulip 各业务模块中锁调用背后的设计意图。
问题的本质:读-改-写序列为什么需要行锁
当一个事务执行"先读取、后写入"的操作序列,且正确性依赖于它读取到的状态时,就必须对该行加锁,把该操作与其他写入者串行化。如果不加锁,另一个事务可能在"读取"与"写入"之间提交,导致当前事务基于过期数据做出决策,两个请求或 worker 最终会应用相互冲突的更新。
典型的失败场景是经典的计数更新:两个请求同时读到some_number = 5,各自加 1 后写回,最终结果是6而不是预期的7。这种丢失更新(lost update)正是行锁要消除的竞态。
Django 通过QuerySet.select_for_update()提供行锁能力。它根据传入的no_key布尔参数生成SELECT ... FOR NO KEY UPDATE或SELECT ... FOR UPDATE两种查询。每个调用点的关键决策,就是选择合适的锁强度——这既是正确性问题,也是并发性能问题。
select_for_update() 的工作机制
select_for_update()会发出一个SELECT ... FOR ...查询,锁定选中的行,直到所在事务提交或回滚。尝试对这些行获取冲突锁的其他事务将被阻塞。
Django 还提供了两个可选参数来改变阻塞行为:
nowait=True:如果无法立即获取锁,直接失败报错,而不是无限期等待;skip_locked=True:跳过当前无法加锁的行,只处理能立即锁住的行。
select_for_update()是以下代码路径的正确工具:在同一个事务里,正确性依赖"先读到稳定值,再基于该值写入"。但必须强调:这种保护只有在其他所有修改同一数据集的代码路径也使用合适的行锁时才成立。任何一个写入路径漏加锁,都可能绕过保护,使整个一致性保证失效。
锁的生命周期与事务边界
由于锁会一直持有到事务结束,一旦获取锁,就应该尽量保持transaction.atomic()块足够小,减少锁的持有时间,降低对并发的负面影响。
锁强度:no_key 参数映射 PostgreSQL 行级锁
Django 的select_for_update(no_key=...)直接映射到 PostgreSQL 行级锁:
| 参数 | 生成的锁 | 强度 | 说明 |
|---|---|---|---|
no_key=True | FOR NO KEY UPDATE | 较弱 | 阻止对锁定行进行并发写入,但不阻止FOR KEY SHARE |
no_key=False | FOR UPDATE | 较强 | 与FOR KEY SHARE冲突,会阻塞其他表引用该行时的插入 |
关键差异在于:FOR UPDATE与FOR KEY SHARE冲突。FOR KEY SHARE是 PostgreSQL 在插入/更新引用行时,为外键检查自动获取的锁。因此,对父行加FOR UPDATE会阻塞其他表中引用该行的插入操作。
而FOR NO KEY UPDATE依然能防止对锁定行的并发写入,但不会阻塞FOR KEY SHARE,从而避免不必要的跨表竞争。这通常是正确的默认选择,除非当前事务有可能删除所选行。
关于 PostgreSQL 行级锁的完整冲突矩阵,可查阅 PostgreSQL 官方行级锁文档。
如何选择 no_key=True 还是 no_key=False
使用no_key=True(弱锁)的场合:
- 只更新外键引用之外的非键字段(在 Zulip 当前代码中,实际上就是指行的主键列以外的字段);
- 用于串行化访问,防止丢失更新/竞态;
- 行本身不会被删除,且被外键引用的键列不会改变。
使用no_key=False(强锁)的场合:
- 会删除被锁定的行(或调用可能删除它的辅助代码);
- 会修改被外键引用的键列(如上所述,在 Zulip 当前代码中即主键列);
- 有意需要在事务进行期间,阻止其他表创建指向该行的外键引用。
如果在代码中必须使用no_key=False,务必添加一行简短注释说明为何需要更强锁——这是 Zulip 代码评审中的硬性要求。
一致性加锁顺序:死锁预防
select_for_update()的另一大用途是保证一致的加锁顺序,从而预防死锁。文档给出了两类典型场景:
场景一:并发事务以不一致顺序更新重叠行集。例如事务 A 按id=1, id=2的顺序加锁,事务 B 按id=2, id=1的顺序加锁,二者将死锁:A 等待 B 持有的id=2锁,B 等待 A 持有的id=1锁。通过在select_for_update()上配合一致的order_by(),可以确保两个事务以相同顺序获取锁,消除此类死锁。
场景二:并发事务以不一致顺序更新两个表的行。例如事务 A 先更新Message行、再更新关联的UserMessage行,而事务 B 先更新UserMessage行、再更新Message行,同样会互相等待死锁。预防方式是在事务开头就统一对目标Message加select_for_update(),强制两个事务串行执行。
从 Zulip 源码可以看到这个模式的真实运用:在 zerver/models/messages.py 中,UserMessage的查询先通过select_for_update(of=("self",), no_key=False).order_by(...)加锁并固定遍历顺序,确保批量删除UserMessage时以一致的顺序触碰行;而 zerver/actions/message_edit.py 在删除用户失去访问权限的UserMessage时同样使用order_by("id").select_for_update(no_key=False),将"按 id 顺序加锁"作为死锁规避手段。
项目政策:semgrep 强制显式 no_key
作为项目级强制约束,Zulip 要求每一个select_for_update()调用都必须显式传入no_key=关键字参数,这一规则由 semgrep 静态检查强制执行。规则定义位于 tools/semgrep-py.yml:
- id: select-for-update-require-no-key message: | Call select_for_update() with an explicit no_key= (True/False) kwarg. We should be purposeful about which lock we need. patterns: - pattern: $Q.select_for_update(...) - pattern-not: $Q.select_for_update(..., no_key=..., ...) severity: ERROR规则逻辑非常直白:任何select_for_update(...)调用,如果其参数中不包含no_key=...,即命中ERROR。这从机制上杜绝了"忘记思考锁强度"的可能性,强制开发者对每一处加锁都做出有意识的决策——要么证明弱锁足够,要么明确需要强锁的理由。
典型代码模式与 Zulip 实战案例
模式一:典型更新路径(no_key=True)
原文档给出的通用模板:
with transaction.atomic(): row = Model.objects.select_for_update(no_key=True).get(id=row_id) row.some_number = row.some_number + 1 row.save(update_fields=["some_number"])在 Zulip 源码中,这种"读后写、防丢失更新"的模式随处可见:
- zerver/actions/invites.py:邀请用户前先
Realm.objects.select_for_update(no_key=True).get(id=...)锁住 realm,防止与其他邀请流程并发竞争邀请名额上限(check_invite_limit)的判定。 - zerver/actions/presence.py:更新用户在线状态时,对
UserPresence行加no_key=True锁,串行化同一用户的状态写入。 - zerver/actions/users.py:
user_profile.refresh_from_db(from_queryset=UserProfile.objects.select_for_update(no_key=True)),在更新用户资料前先锁行再刷新,确保基于最新状态写入。 - zerver/lib/user_groups.py:设置用户组的子组关系时,用
NamedUserGroup.objects.select_for_update(no_key=True)锁住所有目标子组,避免并发修改组成员关系导致的竞态。 - zerver/lib/scheduled_messages.py:定时消息 worker 领取待发送消息时加
no_key=True锁,防止多个 worker 重复投递同一条定时消息。
模式二:删除路径(no_key=False)
原文档给出的删除模板:
with transaction.atomic(): row = Model.objects.select_for_update(no_key=False).get(id=row_id) maybe_delete_row(row)Zulip 中的真实删除场景:
- zerver/actions/realm_settings.py:
do_delete_realm删除整个 realm 时,用Realm.objects.select_for_update(no_key=False).get(id=...)获取强锁;随后 zerver/actions/realm_settings.py 在删除 realm 的附件时也对Attachment/ArchivedAttachment行加no_key=False锁,因为附件文件与数据库记录会被物理删除。 - zerver/actions/message_edit.py:用户失去消息访问权限时删除对应
UserMessage行,使用no_key=False,因为后续会执行delete()。
模式三:带 join 的查询只锁自身(of=("self",))
当从带关联(join)的 queryset 中加锁时,应使用of=("self",),除非你确实有意同时锁住关联表:
rows = Message.objects.select_related(*Message.DEFAULT_SELECT_RELATED) \ .select_for_update(of=("self",), no_key=True)Zulip 源码对此有清晰注释佐证。在 zerver/lib/message.py 的access_message中:
base_query = Message.objects.select_related(*Message.DEFAULT_SELECT_RELATED) if lock_message: # We want to lock only the `Message` row, and not the related fields # because the `Message` row only has a possibility of races. # This is used in the message deletion codepath, so we need no_key=False # to acquire a FOR UPDATE lock. base_query = base_query.select_for_update(of=("self",), no_key=False)这里同时展示了两个决策:of=("self",)只锁Message行本身(因为select_related引入了关联表,不加of会连带锁住关联行,扩大锁范围);而no_key=False是因为该路径服务于消息删除流程,需要FOR UPDATE强锁防止消息在事务期间被并发删除。同一文件的 zerver/lib/message.py(access_message_and_usermessage)则因不涉及删除,明确改用no_key=True,并注释说明"此路径不用于任何消息删除流程"。
类似的of=("self",)用法还出现在 zerver/actions/uploads.py(锁定Attachment自身行后再决定是否删除文件)和 zerver/lib/thumbnail.py(缩略图生成时只锁ImageAttachment行自身)。
模式四:锁定范围 + 条件过滤
zerver/lib/push_notifications.py 展示了对过滤后的多行加锁:Device.objects.select_for_update(no_key=True).filter(...),锁住某个用户/领域的全部推送设备后再批量更新,保证推送配置修改的原子性。配合transaction.atomic(),这种"锁一批行再统一处理"的模式能有效串行化对同一实体集合的并发修改。
并发正确性的测试验证
Zulip 用真实的多线程并发测试来验证锁逻辑的正确性。zerver/transaction_tests/test_user_groups.py 中通过RacingThread并发构造"两个线程同时为同一个父组添加不同子组"的竞态:
- 当两个线程添加相互冲突的子组链时(例如共享同一子组的超组关系),断言只有一个线程成功,另一个收到
"Busy lock detected"错误——这正是select_for_update(no_key=True)加锁后,后到的事务检测到锁冲突并失败的结果; - 当添加互不冲突的子组时,断言两个线程全部成功(
success_count=2),证明弱锁没有造成不必要的互相阻塞。
这组测试同时验证了两个关键性质:行锁确实串行化了冲突操作,同时没有过度扩大阻塞范围。
实践要点总结
- 先判断是否需要锁:只有"读后写且正确性依赖所读状态"的代码路径才需要
select_for_update()。 - 默认选弱锁:
no_key=True(FOR NO KEY UPDATE)是通常的默认选择,避免不必要的跨表竞争;仅当会删除行、修改外键引用的键列、或需要阻止外键引用创建时才用no_key=False。 - 强锁必须注释:使用
no_key=False时写明原因,方便评审者判断锁强度是否合理。 - 用
of=("self",)控制锁范围:queryset 带关联时只锁目标行,除非确实需要锁关联表。 - 统一加锁顺序:配合
order_by()固定加锁顺序,多表操作时在事务开头先锁关键行,预防死锁。 - 保持事务小巧:锁持有到事务结束,
transaction.atomic()块应尽可能短。 - 全部调用点都加锁:行锁保护只在所有修改同一数据的路径都加锁时才成立,任何漏网路径都会破坏一致性。
Zulip 通过"文档规范 + semgrep 强制 + 并发测试验证"三层机制,把数据库并发控制从"靠经验"提升为"可检查、可验证"的工程实践。这套方法论可以直接迁移到任何基于 Django + PostgreSQL 的项目中。
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考