你刚给共享配置的更新接口加上 Redis 锁,代码审查也过了。线程拿锁后卡在 GC,恢复时锁已归别人,旧更新还能写进去吗?Redis 管住了锁键,业务存储还得管住过期的写入。
加锁成功,只拿到一段有期限的使用权
以下为示意:两个服务实例更新同一份配置。它们先读取、在内存修改,再把整份配置写回数据库。请求进入实例 A 后,先向 Redis 申请锁:
SET lock:config:42 9f27c6e1a0484db2b35584c792e806ad NX PX 30000
键名后面的十六进制串就是本次请求的随机值,每次申请重新生成。NX 要求键不存在,PX 同时设置过期时间,30000 毫秒即 30 秒。两件事在同一条命令内完成。这样就避开了加锁后进程退出、来不及设置期限的空隙。只有收到成功响应,A 才进入更新逻辑。
这把锁实际是租约:到期后,Redis 允许下一位申请者接手。随机值用于识别这一次持有者。释放时,要在 Redis 内原子比较值再删除,避免 A 误删 B 的新锁。
这已经处理了“锁永久占着”和“删错锁”两类问题。数据库收到更新请求时,却只检查连接凭据和业务条件。普通写接口并不知道 Redis 里谁持锁。锁键与业务数据分处两个系统,使用权到期没有自动变成数据库的拒写条件。
GC 停住了线程,锁的倒计时仍在走
沿这次请求继续追。以加锁时刻为起点,时间单位为秒:
A 在第 0 秒拿到租约,读出旧配置。第 5 秒发生 stop-the-world GC,应用线程全部暂停。第 30 秒租约到期。B 在第 31 秒拿到新锁,读出配置并完成修改。第 32 秒,B 写入成功。
第 40 秒 A 恢复,继续执行暂停前的下一条语句,把内存中的旧配置写回。B 刚写好的字段被覆盖。整个过程里,Redis 可以始终只承认一个有效持有者。数据库仍接受了两个先后到达的更新。
![]()
▲ 租约交接没有重叠,旧客户端恢复后的写入仍能覆盖新配置
问题出在第 40 秒的写入入口。租约失效撤回的是锁服务中的资格,A 的线程、内存和数据库连接还在。A 无须再次加锁,代码就能走到写入语句。
把 TTL 调大,只是把触发窗口推远。自动续租能覆盖正常的长任务。续租线程若在同一进程里,也会被整进程停顿一起卡住。续租失败后,客户端应停止后续工作。已经发出的请求却还可能在网络中等待。
写前再查一次锁也留着间隙:检查成功,随后暂停,恢复后才发送更新。要封住这条路径,判断必须和真正修改资源发生在同一个受控入口。
换成 Redlock,过期写入仍要单独处理
A 即使改用 Redlock,加锁后的暂停仍需要在写入端处理。
Redlock 使用多个独立 Redis 主节点。拿到多数节点后,还要扣除申请耗时,留出时钟漂移余量。antirez 在回应 Martin Kleppmann 的《Is Redlock safe?》中强调:加锁途中发生长延迟,客户端会重算剩余有效期,超时就放弃。把迟到响应直接当作有效锁,漏掉了算法的检查。
Martin 的《How to do distributed locking》更关心资源的正确性:即便加锁判断正确,之后的暂停与请求延迟仍会让旧工作越过租约。antirez 则强调系统计时假设,以及部分外部资源无法检查令牌的现实。若存储已支持条件更新,部分场景可以直接利用它。双方讨论涉及的授锁安全与资源保护,是两层问题。
新持有者写入后,存储必须挡住旧令牌
fencing token 是随每次成功授锁递增的序号,可理解为访问资源的代际编号。随机解锁值辨认“是不是我”,递增序号辨认“是不是旧的一代”。
这里改用能随授锁返回递增令牌的协议。原来的 SET 和 Redlock 本身不提供这项能力。A 获得 41,B 获得 42,每次写入都带上自己的序号。存储保存已接受的最大序号;接受 B 的 42 后,再收到 A 的 41 就拒绝。
配置 id 唯一,last_fence 非空且初值为 0,每次授锁只提交一次。PostgreSQL 的单行条件更新如下:
UPDATE config
SET payload = :new_payload, last_fence = :token
WHERE id = :id AND last_fence < :token
RETURNING id;
对配置 id=42,B 传入令牌 42 并成功提交,返回 id=42。随后 A 传入令牌 41,返回空结果,B 的内容保留。
序号检查、保存序号和写入内容在同一条语句中完成。在 Read Committed 下,更新遇到同一行的未结束写事务,会先等待。前一事务提交后,再按新行重查条件。返回空结果,调用方就应放弃这份旧计算。如需重试,重新申请、重新读取,再计算。
41 先到、42 尚未被存储接受时,41 仍可能成功。fencing 保证存储接受新一代之后拒绝旧一代。单靠序号检查无法感知租约刚刚过期。同一 token 再次提交,也会被拒绝。成功响应丢失时,用业务幂等键或结果查询确认结果。
![]()
▲ 原子检查以存储已接受的高水位拒旧,不能只凭租约是否过期拒写
一种具体实现是用 etcd 3.6 的事务争抢同一个锁键。先创建租约,再在一次事务里检查键不存在,成立才写入带租约的键;成功事务的 revision 就是令牌。
发号状态由 etcd 集群持久化,授锁与编号在同次提交中确定。正常重启、主节点切换沿用已提交状态;从旧快照恢复须另行处理序号回退。
revision 会跳号,41/42 只是前面两代的简化编号。在 Redis 加锁后独立执行 INCR 则有漏洞:A 暂停到 B 完成后才取号,反而拿到更大的号码。
存储侧要持久化序号,所有写入入口都执行检查。外部接口只有幂等键时,能去重同一请求;A 换个请求号提交旧内容,仍可能覆盖 B。若接口支持按资源版本条件更新,B 将版本从 v1 改为 v2 后,A 携带旧版本 v1 的更新就会被拒绝。接口既不检查代际,也不比较资源版本时,加锁和请求去重仍挡不住这种旧内容覆盖。
IT架构师联盟
面向架构师与工程同行,解析AI Agent、系统架构与可靠性,讲清技术机制、工程边界和实践方法。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.