真实面经题目 · 原创解析

Redis 分布式锁如何实现?

Redis 分布式锁的核心不是用一个 key 表示占用,而是要同时解决互斥、死锁、误删、超时、续期、主从切换和业务兜底。标准实现通常是 SET key token NX PX ttl 获取锁,用唯一 token 标识持有者,用 Lua 脚本先比较 token 再删除来释放锁。

出现于:阿里巴巴 · 后端开发

60 秒回答模板

Redis 可以用来做分布式锁,是因为它是多个进程都能访问的外部共享存储,并且单条命令执行具备原子性。实现时不能用先 SETNX 再 EXPIRE 的两步写法,而应使用 SET lockKey uniqueToken NX PX expireMillis 一条命令完成“只有不存在才写入”和“设置过期时间”。uniqueToken 用来区分锁的持有者。释放锁时不能直接 DEL,因为业务执行超时后锁可能已经过期并被别人重新获取,直接 DEL 会误删别人的锁,所以要用 Lua 脚本在 Redis 内部原子地判断 value 是否等于自己的 token,相等才删除。过期时间要基于业务最大执行时间、网络抖动和 GC 停顿预留余量;长任务可以使用看门狗续期,但续期不是万能的,进程卡死、长时间 STW、网络分区都可能导致续期失败。异步主从还存在主从切换风险:主节点写入锁后还没同步到从节点就宕机,新主可能没有这把锁。Redlock 试图通过多个独立 Redis 节点多数派加锁降低风险,但它有争议,不能简单等同于强一致锁。稳妥答案是:Redis 锁适合可容忍极小概率重复执行、且业务有幂等兜底的场景;严格线性一致场景要考虑数据库约束、事务、ZooKeeper 或 etcd。

考点 基础语义
主线 正确加锁命令
易错点 只回答 SETNX,不提过期时间和原子设置,忽略死锁风…

深入解析

01

基础语义

分布式锁的本质是让多个进程围绕同一个资源达成互斥协议。Redis 能做这件事,是因为所有竞争方都访问同一个外部存储,并且 Redis 对单条命令的执行是原子的,不会出现两个客户端在同一个 key 不存在时同时写成功。这个能力只能提供互斥的基础条件,还不能自动保证业务执行只发生一次。完整方案还必须处理持有者身份、锁过期、释放原子性、故障切换和业务重试。

02

正确加锁命令

推荐的加锁方式是使用 SET key value NX PX ttl,一条命令同时完成不存在才设置和设置毫秒级过期时间。NX 负责互斥,PX 负责死锁兜底,value 负责标识持有者。不要使用 SETNX 后再 EXPIRE 的两步方案,因为进程可能在 SETNX 成功后、EXPIRE 执行前崩溃,导致 key 永不过期。也不要只用固定字符串作为 value,否则释放时无法判断锁是否仍属于当前请求。

03

唯一 token

锁的 value 必须是唯一 token,通常可以由服务实例 id、线程 id、请求 id 和随机数共同构造。它的作用不是为了加锁成功,而是为了释放锁时证明这个锁还是自己加的。如果业务执行时间超过 TTL,锁会自动过期,其他客户端可能已经获得同一个 key。此时旧客户端执行完后如果直接 DEL,就会删除新客户端的锁,造成临界区并发。

04

Lua 原子释放

释放锁时应使用 Lua 脚本在 Redis 内部完成 get、compare、del 三个动作,保证判断和删除之间没有其他客户端插入。伪逻辑是:如果 key 的 value 等于自己的 token,则删除;否则什么都不做。不能在客户端先 GET 再 DEL,因为 GET 返回后到 DEL 执行前,锁可能已经过期并被别人重建。Lua 的价值在于把复合检查变成 Redis 侧的单次原子执行。

05

TTL 与续期

TTL 决定锁的安全窗口和故障恢复速度。过短会导致业务还没执行完锁就过期,引发并发执行;过长会导致持锁进程崩溃后其他请求等待时间过久。一般要基于临界区的 P99 或 P999 执行时间,加上网络延迟、调度抖动、GC 停顿、下游慢调用等缓冲。长任务可引入看门狗续期,但进程暂停、线程池饱和、网络隔离或 Redis 不可用时,续期仍可能失败。

06

主从切换风险

常见 Redis 部署使用异步复制,这会带来锁状态丢失风险。客户端在主节点加锁成功后,如果主节点尚未把 key 复制到从节点就宕机,从节点被提升为新主,那么另一个客户端可能在新主上再次加锁成功。这样两个客户端都认为自己持有锁。这个问题不是 SET NX PX 或 Lua 能解决的,而是复制模型决定的,强一致要求高的场景要明确这个边界。

07

Redlock 边界

Redlock 的思路是在多个相互独立的 Redis 节点上尝试加锁,只有在多数节点成功且总耗时小于有效期时才认为获取成功。它比单节点或普通主从更能抵抗某些节点故障,但它也有争议:算法依赖时钟漂移、网络延迟上界和故障模型假设,在极端分区、暂停和超时场景下仍可能出现安全性讨论。面试中不要把 Redlock 说成银弹。

08

业务兜底

再完善的 Redis 锁也只能降低并发进入临界区的概率,不能代替业务正确性。涉及扣库存、发券、转账、任务调度等场景时,应使用唯一业务单号、状态机、数据库唯一约束、乐观锁版本号、幂等表或消息去重来兜底。这样即使锁因为超时、切换或重试导致重复执行,业务层也能拒绝重复变更。成熟回答应把锁定位为流量协调手段,而不是唯一正确性边界。

09

锁粒度与可重入

锁粒度要围绕真实竞争资源设计,例如按商品 id、订单 id、用户 id 或任务 id 加锁,而不是粗暴使用全局锁。粒度过粗会降低吞吐,粒度过细又可能无法覆盖真实冲突。Redis 本身不天然支持可重入锁,如果要支持,需要在 value 中维护 owner 信息和重入计数,并用 Lua 原子加减;但这会提高复杂度,跨线程、跨进程和异常退出时边界更难处理。

java

Redis 分布式锁加锁与安全释放

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)
  );
}
  • 加锁要把 NX 和过期时间放在同一条原子命令里。
  • 释放锁必须先校验 owner,再删除,避免误删别人后来获得的锁。

易错点

  • 只回答 SETNX,不提过期时间和原子设置,忽略死锁风险。
  • 使用 SETNX 后再 EXPIRE,把两步操作误认为安全实现。
  • 释放锁时直接 DEL,不校验唯一 token,可能误删别人的锁。
  • 客户端先 GET 再 DEL,不知道这两个动作之间存在竞态。
  • 把 Redis 单线程等同于分布式强一致,忽略主从异步复制和故障切换风险。
  • 认为设置了 TTL 就万事大吉,不考虑业务执行超过 TTL 后的并发执行。
  • 把 Redlock 描述成行业无争议标准答案,不说明它的假设和适用边界。
  • 忽略业务幂等、唯一约束和状态校验,把正确性全部依赖在 Redis 锁上。

面试官追问

为什么不能用 SETNX 后再 EXPIRE?

因为这是两条命令,中间如果客户端崩溃、线程被杀或网络断开,就可能只写入锁但没有设置过期时间,形成永久死锁。正确方式是用 SET key token NX PX ttl 一次完成。

为什么释放锁不能直接 DEL?

因为锁可能已经因 TTL 到期而释放,并被另一个客户端重新获取。旧持有者再 DEL 会误删新持有者的锁,所以必须比较 token 后再删除。

Lua 脚本解决了什么问题?

它把读取 value、比较 token、删除 key 组合成 Redis 内部的一次原子执行,避免客户端 GET 和 DEL 之间发生锁过期或被重建的竞态。

看门狗续期有什么风险?

看门狗依赖持锁进程和续期线程正常运行。如果进程暂停、线程池阻塞、网络隔离或 Redis 不可达,续期可能失败。业务不能因为有续期就取消幂等和超时控制。

什么场景不适合只靠 Redis 锁?

对重复执行零容忍的核心资金、账务、强一致库存扣减等场景不适合只靠 Redis 锁。应把数据库约束、事务、状态机、幂等键或一致性协调服务作为正确性边界。