day38 Redis分布式锁实现、误删问题与Lua脚本原子性
一、分布式锁概述
1.1 为什么需要分布式锁
之前学习秒杀超卖和一人一单问题时,使用 synchronized 或 Lock 解决了单机环境下的并发安全。但 synchronized 依赖 JVM 内部的锁监视器,只在单个 JVM 进程内有效。集群部署时每个 Tomcat 拥有独立的 JVM,相同代码中的锁对象在不同 JVM 中不是同一个,无法实现跨进程互斥。
分布式锁的核心思想:让所有进程使用同一把锁,多进程可见且互斥,从而保证集群环境下的串行执行。
1.2 分布式锁的核心条件
| 条件 | 说明 |
|---|---|
| 多进程可见 | 不同 JVM 中的线程都能看到同一把锁的状态 |
| 互斥 | 基本条件,保证同一时刻只有一个进程获取锁 |
| 高可用 | 进程不易崩溃,时刻保证锁服务可用 |
| 高性能 | 加锁和释放锁本身不能成为性能瓶颈 |
| 安全性 | 避免死锁等安全问题 |
1.3 三种实现方式对比
| 实现方式 | 互斥原理 | 性能 | 可靠性 |
|---|---|---|---|
| MySQL | 利用数据库自带的锁机制 | 一般 | 较好 |
| Redis | 利用 setnx,插入 key 成功即获取锁 |
高 | 高 |
| Zookeeper | 利用节点唯一性和临时节点 | 较高 | 高 |
本次使用 Redis 实现,互斥原理是 setnx(即 SET key value NX EX),与之前缓存击穿时使用的互斥锁方案类似,都是利用 Redis 的 setnx 实现互斥。Redis 高性能、高可用,配合过期时间可以避免死锁。
二、Redis分布式锁基本实现
2.1 实现思路
获取锁使用 setIfAbsent(对应 Redis 的 SET NX EX),一次调用同时完成设置 key-value 和过期时间,保证加锁和设置过期时间的原子性。释放锁使用 DEL 删除 key。
项目中定义了锁接口 ILock,由 SimpleRedisLock 实现具体逻辑。
2.2 锁接口
1 | public interface ILock { |
tryLock 为非阻塞式获取锁:尝试一次,成功返回 true,失败返回 false,不等待重试。
2.3 加锁实现
1 | public class SimpleRedisLock implements ILock { |
关键点:
setIfAbsent对应SET key value NX EX seconds,key 不存在才设置,同时指定过期时间,保证加锁和设过期时间原子性- key 格式为
lock:业务名,如lock:order:1 - value 是线程标识,用于后续判断锁归属(见第三节)
- 过期时间防止死锁,但如果业务执行时间超过过期时间,锁会提前释放
2.4 业务接入
VoucherOrderServiceImpl 中使用分布式锁解决一人一单问题:
1 |
|
注意:锁的粒度是 order:userId,按用户维度加锁,不同用户可以并行下单。使用 AopContext.currentProxy() 获取代理对象调用事务方法,确保事务在锁内部生效。
三、误删问题与线程标识
3.1 问题描述
基本实现中释放锁只是简单 DEL,不判断锁的归属,可能导致误删别人锁:
1 | 线程1 获取锁 → 阻塞 → 锁超时释放 |
以此类推,线程2 也可能删除其他线程的锁,互斥性被破坏。
3.2 解决方案
在锁的 value 中存入线程标识,释放锁前先比较:只有锁的 value 与当前线程标识一致时才删除。
不同 JVM 下线程 id 可能重复,所以使用 UUID + 线程 id 组合作为线程标识:
- UUID 使用
static final静态属性,每个 JVM 唯一,不同 JVM 下 UUID 不同 - 同一 JVM 内线程 id 不同
1 | private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; |
UUID.randomUUID().toString(true) 中的 true 表示去掉横杠,生成紧凑格式的 UUID。
3.3 存在原子性隐患的释放逻辑
最初改进后的释放锁逻辑如下(已被项目注释掉):
1 | public void unlock() { |
这段代码解决了误删问题,但引入了新的隐患——原子性问题。
四、原子性问题与Lua脚本
4.1 原子性问题
上述释放锁逻辑中,获取锁的线程标识、比较、删除锁是分三次 Redis 操作完成的:
1 | 线程1 GET 锁 → 判断一致 → 阻塞 → 锁超时过期 |
线程1 在判断通过后、执行删除前发生了阻塞,锁在此期间超时过期,线程2 拿到锁。线程1 恢复后直接删除,删的是线程2 的锁。原因是判断和删除不是原子操作。
4.2 Lua脚本操作Redis
Redis 提供 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。
基本调用语法:
1 | redis.call('命令名称', 'key', '其它参数', ...) |
通过 EVAL 命令执行脚本:
1 | EVAL script numkeys key [key ...] arg [arg ...] |
numkeys:key 参数的个数- 前
numkeys个参数放入KEYS数组,脚本中用KEYS[1]、KEYS[2]访问 - 其余参数放入
ARGV数组,脚本中用ARGV[1]、ARGV[2]访问 - 下标从 1 开始
4.3 项目中的unlock.lua
将释放锁的判断+删除逻辑封装为一个 Lua 脚本,一次提交给 Redis 执行,保证原子性。脚本位于 src/main/resources/unlock.lua:
1 | if ARGV[1]==redis.call("get",KEYS[1]) then |
逻辑:获取锁的 value,判断是否与当前线程标识(ARGV[1])一致,一致则删除锁(返回删除结果),不一致则返回 0。
4.4 Java调用Lua脚本
SimpleRedisLock 使用 Spring 提供的 DefaultRedisScript 加载并执行 Lua 脚本:
1 | private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; |
关键点:
DefaultRedisScript在静态代码块中初始化,只加载一次,避免重复 IOsetLocation从 classpath 加载unlock.lua文件setResultType(Long.class)指定返回值类型execute的参数:第一个是脚本对象,第二个是 KEYS 列表(Collections.singletonList传入一个 key),第三个是 ARGV 参数(线程标识)- 整个释放锁过程只需一次 Redis 调用,判断和删除原子完成
五、演进总结
整个 Redis 分布式锁的改进过程是逐步发现和解决问题的过程:
| 版本 | 问题 | 解决方式 |
|---|---|---|
| 基本实现 | 无判断直接 DEL,可能误删 | - |
| 改进一 | 误删别人锁 | 存入 UUID+线程ID,释放前比较标识 |
| 改进二 | 判断和删除非原子 | Lua 脚本封装判断+删除,一次执行 |
注意:当前实现仍有不足:锁不可重入、获取失败不能重试、过期时间固定无法续期、主从同步延迟可能丢锁。这些问题需要 Redisson 解决,本次未涉及。
今日总结
| 模块 | 核心要点 |
|---|---|
| 分布式锁概念 | 集群环境下多进程可见且互斥的锁,synchronized 只在单 JVM 有效 |
| Redis 实现原理 | SET NX EX 保证加锁互斥 + 防死锁,非阻塞返回 |
| 线程标识 | UUID + 线程ID 组合,跨 JVM 唯一标识锁归属 |
| 误删问题 | 阻塞导致锁超时释放,释放时误删别人的锁 |
| 原子性问题 | 判断标识和删除锁分多步,中间阻塞会导致误删 |
| Lua 脚本 | 将多条 Redis 命令封装为一次原子执行,EVAL 调用 |
| 项目实现 | SimpleRedisLock 实现接口,unlock.lua 保证释放原子性 |