Zulip 数据库并发控制实战:深入理解 select_for_update() 与行级锁的正确用法
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/zulipZulip 是一个高性能的开源团队聊天服务其服务器端大量使用 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 还提供了两个可选参数来改变阻塞行为nowaitTrue如果无法立即获取锁直接失败报错而不是无限期等待skip_lockedTrue跳过当前无法加锁的行只处理能立即锁住的行。select_for_update()是以下代码路径的正确工具在同一个事务里正确性依赖先读到稳定值再基于该值写入。但必须强调这种保护只有在其他所有修改同一数据集的代码路径也使用合适的行锁时才成立。任何一个写入路径漏加锁都可能绕过保护使整个一致性保证失效。锁的生命周期与事务边界由于锁会一直持有到事务结束一旦获取锁就应该尽量保持transaction.atomic()块足够小减少锁的持有时间降低对并发的负面影响。锁强度no_key 参数映射 PostgreSQL 行级锁Django 的select_for_update(no_key...)直接映射到 PostgreSQL 行级锁参数生成的锁强度说明no_keyTrueFOR NO KEY UPDATE较弱阻止对锁定行进行并发写入但不阻止FOR KEY SHAREno_keyFalseFOR 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_keyTrue 还是 no_keyFalse使用no_keyTrue弱锁的场合只更新外键引用之外的非键字段在 Zulip 当前代码中实际上就是指行的主键列以外的字段用于串行化访问防止丢失更新/竞态行本身不会被删除且被外键引用的键列不会改变。使用no_keyFalse强锁的场合会删除被锁定的行或调用可能删除它的辅助代码会修改被外键引用的键列如上所述在 Zulip 当前代码中即主键列有意需要在事务进行期间阻止其他表创建指向该行的外键引用。如果在代码中必须使用no_keyFalse务必添加一行简短注释说明为何需要更强锁——这是 Zulip 代码评审中的硬性要求。一致性加锁顺序死锁预防select_for_update()的另一大用途是保证一致的加锁顺序从而预防死锁。文档给出了两类典型场景场景一并发事务以不一致顺序更新重叠行集。例如事务 A 按id1, id2的顺序加锁事务 B 按id2, id1的顺序加锁二者将死锁A 等待 B 持有的id2锁B 等待 A 持有的id1锁。通过在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_keyFalse).order_by(...)加锁并固定遍历顺序确保批量删除UserMessage时以一致的顺序触碰行而 zerver/actions/message_edit.py 在删除用户失去访问权限的UserMessage时同样使用order_by(id).select_for_update(no_keyFalse)将按 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_keyTrue原文档给出的通用模板with transaction.atomic(): row Model.objects.select_for_update(no_keyTrue).get(idrow_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_keyTrue).get(id...)锁住 realm防止与其他邀请流程并发竞争邀请名额上限check_invite_limit的判定。zerver/actions/presence.py更新用户在线状态时对UserPresence行加no_keyTrue锁串行化同一用户的状态写入。zerver/actions/users.pyuser_profile.refresh_from_db(from_querysetUserProfile.objects.select_for_update(no_keyTrue))在更新用户资料前先锁行再刷新确保基于最新状态写入。zerver/lib/user_groups.py设置用户组的子组关系时用NamedUserGroup.objects.select_for_update(no_keyTrue)锁住所有目标子组避免并发修改组成员关系导致的竞态。zerver/lib/scheduled_messages.py定时消息 worker 领取待发送消息时加no_keyTrue锁防止多个 worker 重复投递同一条定时消息。模式二删除路径no_keyFalse原文档给出的删除模板with transaction.atomic(): row Model.objects.select_for_update(no_keyFalse).get(idrow_id) maybe_delete_row(row)Zulip 中的真实删除场景zerver/actions/realm_settings.pydo_delete_realm删除整个 realm 时用Realm.objects.select_for_update(no_keyFalse).get(id...)获取强锁随后 zerver/actions/realm_settings.py 在删除 realm 的附件时也对Attachment/ArchivedAttachment行加no_keyFalse锁因为附件文件与数据库记录会被物理删除。zerver/actions/message_edit.py用户失去消息访问权限时删除对应UserMessage行使用no_keyFalse因为后续会执行delete()。模式三带 join 的查询只锁自身of(self,)当从带关联join的 queryset 中加锁时应使用of(self,)除非你确实有意同时锁住关联表rows Message.objects.select_related(*Message.DEFAULT_SELECT_RELATED) \ .select_for_update(of(self,), no_keyTrue)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_keyFalse # to acquire a FOR UPDATE lock. base_query base_query.select_for_update(of(self,), no_keyFalse)这里同时展示了两个决策of(self,)只锁Message行本身因为select_related引入了关联表不加of会连带锁住关联行扩大锁范围而no_keyFalse是因为该路径服务于消息删除流程需要FOR UPDATE强锁防止消息在事务期间被并发删除。同一文件的 zerver/lib/message.pyaccess_message_and_usermessage则因不涉及删除明确改用no_keyTrue并注释说明此路径不用于任何消息删除流程。类似的of(self,)用法还出现在 zerver/actions/uploads.py锁定Attachment自身行后再决定是否删除文件和 zerver/lib/thumbnail.py缩略图生成时只锁ImageAttachment行自身。模式四锁定范围 条件过滤zerver/lib/push_notifications.py 展示了对过滤后的多行加锁Device.objects.select_for_update(no_keyTrue).filter(...)锁住某个用户/领域的全部推送设备后再批量更新保证推送配置修改的原子性。配合transaction.atomic()这种锁一批行再统一处理的模式能有效串行化对同一实体集合的并发修改。并发正确性的测试验证Zulip 用真实的多线程并发测试来验证锁逻辑的正确性。zerver/transaction_tests/test_user_groups.py 中通过RacingThread并发构造两个线程同时为同一个父组添加不同子组的竞态当两个线程添加相互冲突的子组链时例如共享同一子组的超组关系断言只有一个线程成功另一个收到Busy lock detected错误——这正是select_for_update(no_keyTrue)加锁后后到的事务检测到锁冲突并失败的结果当添加互不冲突的子组时断言两个线程全部成功success_count2证明弱锁没有造成不必要的互相阻塞。这组测试同时验证了两个关键性质行锁确实串行化了冲突操作同时没有过度扩大阻塞范围。实践要点总结先判断是否需要锁只有读后写且正确性依赖所读状态的代码路径才需要select_for_update()。默认选弱锁no_keyTrueFOR NO KEY UPDATE是通常的默认选择避免不必要的跨表竞争仅当会删除行、修改外键引用的键列、或需要阻止外键引用创建时才用no_keyFalse。强锁必须注释使用no_keyFalse时写明原因方便评审者判断锁强度是否合理。用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),仅供参考