真实面经题目 · 原创解析
Redis 分布式锁如何实现?
Redis 分布式锁的核心不是用一个 key 表示占用,而是要同时解决互斥、死锁、误删、超时、续期、主从切换和业务兜底。标准实现通常是 SET key token NX PX ttl 获取锁,用唯一 token 标识持有者,用 Lua 脚本先比较 token 再删除来释放锁。
真实面经题目 · 原创解析
Redis 分布式锁的核心不是用一个 key 表示占用,而是要同时解决互斥、死锁、误删、超时、续期、主从切换和业务兜底。标准实现通常是 SET key token NX PX ttl 获取锁,用唯一 token 标识持有者,用 Lua 脚本先比较 token 再删除来释放锁。
Redis 可以用来做分布式锁,是因为它是多个进程都能访问的外部共享存储,并且单条命令执行具备原子性。实现时不能用先 SETNX 再 EXPIRE 的两步写法,而应使用 SET lockKey uniqueToken NX PX expireMillis 一条命令完成“只有不存在才写入”和“设置过期时间”。uniqueToken 用来区分锁的持有者。释放锁时不能直接 DEL,因为业务执行超时后锁可能已经过期并被别人重新获取,直接 DEL 会误删别人的锁,所以要用 Lua 脚本在 Redis 内部原子地判断 value 是否等于自己的 token,相等才删除。过期时间要基于业务最大执行时间、网络抖动和 GC 停顿预留余量;长任务可以使用看门狗续期,但续期不是万能的,进程卡死、长时间 STW、网络分区都可能导致续期失败。异步主从还存在主从切换风险:主节点写入锁后还没同步到从节点就宕机,新主可能没有这把锁。Redlock 试图通过多个独立 Redis 节点多数派加锁降低风险,但它有争议,不能简单等同于强一致锁。稳妥答案是:Redis 锁适合可容忍极小概率重复执行、且业务有幂等兜底的场景;严格线性一致场景要考虑数据库约束、事务、ZooKeeper 或 etcd。
分布式锁的本质是让多个进程围绕同一个资源达成互斥协议。Redis 能做这件事,是因为所有竞争方都访问同一个外部存储,并且 Redis 对单条命令的执行是原子的,不会出现两个客户端在同一个 key 不存在时同时写成功。这个能力只能提供互斥的基础条件,还不能自动保证业务执行只发生一次。完整方案还必须处理持有者身份、锁过期、释放原子性、故障切换和业务重试。
推荐的加锁方式是使用 SET key value NX PX ttl,一条命令同时完成不存在才设置和设置毫秒级过期时间。NX 负责互斥,PX 负责死锁兜底,value 负责标识持有者。不要使用 SETNX 后再 EXPIRE 的两步方案,因为进程可能在 SETNX 成功后、EXPIRE 执行前崩溃,导致 key 永不过期。也不要只用固定字符串作为 value,否则释放时无法判断锁是否仍属于当前请求。
锁的 value 必须是唯一 token,通常可以由服务实例 id、线程 id、请求 id 和随机数共同构造。它的作用不是为了加锁成功,而是为了释放锁时证明这个锁还是自己加的。如果业务执行时间超过 TTL,锁会自动过期,其他客户端可能已经获得同一个 key。此时旧客户端执行完后如果直接 DEL,就会删除新客户端的锁,造成临界区并发。
释放锁时应使用 Lua 脚本在 Redis 内部完成 get、compare、del 三个动作,保证判断和删除之间没有其他客户端插入。伪逻辑是:如果 key 的 value 等于自己的 token,则删除;否则什么都不做。不能在客户端先 GET 再 DEL,因为 GET 返回后到 DEL 执行前,锁可能已经过期并被别人重建。Lua 的价值在于把复合检查变成 Redis 侧的单次原子执行。
TTL 决定锁的安全窗口和故障恢复速度。过短会导致业务还没执行完锁就过期,引发并发执行;过长会导致持锁进程崩溃后其他请求等待时间过久。一般要基于临界区的 P99 或 P999 执行时间,加上网络延迟、调度抖动、GC 停顿、下游慢调用等缓冲。长任务可引入看门狗续期,但进程暂停、线程池饱和、网络隔离或 Redis 不可用时,续期仍可能失败。
常见 Redis 部署使用异步复制,这会带来锁状态丢失风险。客户端在主节点加锁成功后,如果主节点尚未把 key 复制到从节点就宕机,从节点被提升为新主,那么另一个客户端可能在新主上再次加锁成功。这样两个客户端都认为自己持有锁。这个问题不是 SET NX PX 或 Lua 能解决的,而是复制模型决定的,强一致要求高的场景要明确这个边界。
Redlock 的思路是在多个相互独立的 Redis 节点上尝试加锁,只有在多数节点成功且总耗时小于有效期时才认为获取成功。它比单节点或普通主从更能抵抗某些节点故障,但它也有争议:算法依赖时钟漂移、网络延迟上界和故障模型假设,在极端分区、暂停和超时场景下仍可能出现安全性讨论。面试中不要把 Redlock 说成银弹。
再完善的 Redis 锁也只能降低并发进入临界区的概率,不能代替业务正确性。涉及扣库存、发券、转账、任务调度等场景时,应使用唯一业务单号、状态机、数据库唯一约束、乐观锁版本号、幂等表或消息去重来兜底。这样即使锁因为超时、切换或重试导致重复执行,业务层也能拒绝重复变更。成熟回答应把锁定位为流量协调手段,而不是唯一正确性边界。
锁粒度要围绕真实竞争资源设计,例如按商品 id、订单 id、用户 id 或任务 id 加锁,而不是粗暴使用全局锁。粒度过粗会降低吞吐,粒度过细又可能无法覆盖真实冲突。Redis 本身不天然支持可重入锁,如果要支持,需要在 value 中维护 owner 信息和重入计数,并用 Lua 原子加减;但这会提高复杂度,跨线程、跨进程和异常退出时边界更难处理。
String owner = UUID.randomUUID().toString();
Boolean locked = redis.set(lockKey, owner, SetArgs.Builder.nx().px(30_000));
if (!Boolean.TRUE.equals(locked)) {
return false;
}
try {
doBusiness();
} finally {
redis.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end",
List.of(lockKey),
List.of(owner)
);
} 因为这是两条命令,中间如果客户端崩溃、线程被杀或网络断开,就可能只写入锁但没有设置过期时间,形成永久死锁。正确方式是用 SET key token NX PX ttl 一次完成。
因为锁可能已经因 TTL 到期而释放,并被另一个客户端重新获取。旧持有者再 DEL 会误删新持有者的锁,所以必须比较 token 后再删除。
它把读取 value、比较 token、删除 key 组合成 Redis 内部的一次原子执行,避免客户端 GET 和 DEL 之间发生锁过期或被重建的竞态。
看门狗依赖持锁进程和续期线程正常运行。如果进程暂停、线程池阻塞、网络隔离或 Redis 不可达,续期可能失败。业务不能因为有续期就取消幂等和超时控制。
对重复执行零容忍的核心资金、账务、强一致库存扣减等场景不适合只靠 Redis 锁。应把数据库约束、事务、状态机、幂等键或一致性协调服务作为正确性边界。