在使用 Redis 实现分布式锁时,看似简单(一个 SETNX 搞定),但在高并发和分布式环境下,要做到可靠性和安全性,需要深入考虑以下几个核心问题:
1. 互斥性与原子性 (Atomicity)
这是分布式锁最基本的要求。必须保证在任何时刻,只有一个客户端能持有锁。
- 解决方案:不能先用
EXISTS判断再用SET,这存在竞态条件。必须使用原子指令。 - 最佳实践:使用
SET key value NX PX milliseconds。其中NX保证只有不存在时才设置成功,PX保证原子性地设置过期时间。
2. 防死锁 (Deadlock Prevention)
如果客户端获取锁后崩溃或网络中断,锁没有被显式释放,其他客户端将永远无法获取锁。
- 解决方案:必须为锁设置过期时间(TTL)。
- 注意:过期时间不能设置得太短(任务没执行完就过期)或太长(崩溃后恢复慢)。
3. 锁的错误释放 (Safe Unlock)
客户端 A 获取了锁,但由于执行任务过长,导致锁自动过期。此时客户端 B 获取了锁。随后客户端 A 执行完任务,调用 DEL 释放锁,却把客户端 B 正在使用的锁给删了。
- 解决方案:解铃还须系铃人。
- 在加锁时,
value必须是一个唯一标识符(如 UUID)。 - 在释放锁时,先检查锁的
value是否等于自己的标识符,相等才删除。 - 原子性要求:检查和删除必须是原子的,通常使用 Lua 脚本来实现。
- 在加锁时,
-- Lua 脚本:释放锁
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
4. 锁的自动续期 (Lock Renewal)
如果业务逻辑执行时间超过了锁的过期时间,锁被自动释放会导致并发安全问题。
- 解决方案:看门狗机制 (Watch Dog)。
- 在获取锁成功后,开启一个后台守护线程。
- 每隔一段时间(如过期时间的 1/3)检查锁是否还存在,如果存在则重置过期时间。
- Redisson(Java 客户端)就内置了这种机制。
5. 集群环境下的可靠性 (Redlock)
在 Redis 主从架构或哨兵模式下,主从同步是异步的。
- 风险点:客户端 A 在 Master 上加锁成功,Master 还没来得及同步给 Slave 就挂了。Slave 升级为新 Master,客户端 B 请求加锁也能成功。此时两个客户端同时持有锁。
- 解决方案:Redis 官方提出了 Redlock 算法。
- 在多个(通常是 5 个)独立的 Redis 实例上尝试加锁。
- 只有在超过半数(N/2 + 1)的节点加锁成功,且总耗时小于锁有效时间时,才认为加锁成功。
- 注:Redlock 在业界仍有争议,但在绝大多数业务场景下,单机/哨兵模式配合合理的超时机制已足够。
6. 锁等待策略 (Blocking vs Polling)
当锁被占用时,未获取到锁的客户端该怎么办?
- 轮询 (Polling):不断尝试加锁。缺点是浪费 CPU,建议增加退避算法(如随机延迟)。
- 发布/订阅 (Pub/Sub):客户端订阅锁释放的消息,收到消息后再尝试获取。这能显著降低 Redis 的压力。
总结对照表
| 考虑维度 | 核心风险 | 应对方案 |
|---|---|---|
| 原子性 | 加锁与设置超时非原子执行 | SET key value NX PX |
| 安全性 | A 释放了 B 的锁 | 唯一标识 (UUID) + Lua 脚本 |
| 可靠性 | 业务没跑完锁过期了 | 看门狗 (Watch Dog) 自动续期 |
| 可用性 | Redis Master 宕机同步延迟 | Redlock 算法或增加手动补偿 |
| 性能 | 自旋锁占用 CPU | 结合 Pub/Sub 或限时阻塞 |
示例
import org.apache.commons.lang3.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* 分布式锁实现
*/
public class RedisDistributedLock {
/**
* map默认容量
*/
private static final int DEFAULT_MAP_SIZE = 16;
/**
* 默认锁过期时间/秒
*/
private static final int DEFAULT_TIME_OUT_SECOND = 10;
/**
* 默认锁过期时间/毫秒
*/
private static final int DEFAULT_TIME = DEFAULT_TIME_OUT_SECOND * 1000;
/**
* 表示过期时间单位是milliseconds
*/
private static final String PX = "px";
/**
* 表示当key存在时才set值
*/
private static final String XX = "xx";
/**
* 表示当key不存在时才set值
*/
private static final String NX = "nx";
/**
* 表示过期时间单位是秒
*/
private static final String EX = "ex";
/**
* 加锁成功
*/
private static final String LOCK_SUCCESS = "OK";
/**
* 锁过期时间处理线程池
*/
private static final ScheduledExecutorService executorService = Executors
.newScheduledThreadPool(Runtime.getRuntime().availableProcessors() / 2, new LockThreadFactory());
/**
* 缓存锁与客户端唯一标识的映射
*/
private static final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<String, String>(DEFAULT_MAP_SIZE);
/**
* 处理锁过期时间
*/
private ConcurrentHashMap<String, ScheduledFuture> futureMap = new ConcurrentHashMap<>();
private SecureRandom sr;
public RedisDistributedLock() {
this.sr = new SecureRandom();
}
/**
* 加锁
*
* @param key 加锁的key
* @return 是否加锁成功
*/
public boolean lock(String key) {
return lock(key, DEFAULT_TIME);
}
/**
* 加锁
*
* @param key 加锁的key
* @param timeOut 锁过期时间
* @return 是否加锁成功
*/
public boolean lock(String key, long timeOut) {
if (StringUtils.isEmpty(key)) {
return false;
}
// 随机生成客户id 防止其他线程区释放锁
String clientId = String.valueOf(sr.nextLong());
long startTime = System.currentTimeMillis();
long expireTime = timeOut > 0 && timeOut < DEFAULT_TIME ? timeOut : DEFAULT_TIME;
// 获取锁 原子性 无中断
boolean isLock = lockUnInterrupted(key, clientId, expireTime);
if (isLock) {
map.put(key, clientId);
// 处理业务时间过长导致锁过期,1秒钟定期检查
ScheduledFuture<?> scheduledFuture = executorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
// 超时的话 释放锁 否则锁续命
if (timeOut == 0 || (System.currentTimeMillis() - startTime) > DEFAULT_TIME) {
System.out.println("timeout release lock, key: " + key + " clientId: " + clientId);
unlockUnInterrupted(key, clientId);
} else {
System.out.println("---expire key: " + key + " clientId: " + clientId);
updateExpireTime(key, clientId, expireTime);
}
}
}, 1, 1, TimeUnit.SECONDS);
ScheduledFuture future = futureMap.put(clientId, scheduledFuture);
System.out.println("---------- " + future);
if (future != null) {
future.cancel(false);
}
if (System.currentTimeMillis() - startTime > DEFAULT_TIME) {
System.out.println("lock cost time is " + (System.currentTimeMillis() - startTime) + " second");
}
return true;
}
return false;
}
/**
* 释放分布式锁
*
* @param key 释放锁key
*/
public void unlock(String key) {
if (StringUtils.isEmpty(key)) {
return;
}
String clientId = map.get(key);
if (clientId == null) {
return;
}
map.remove(key);
ScheduledFuture future = futureMap.remove(clientId);
if (future != null) {
future.cancel(false);
}
System.out.println("clear redis lock key: " + key + " clientId: " + clientId);
unlockUnInterrupted(key, clientId);
}
/**
* 更新锁的过期时间
*
* @param key 释放锁key
* @param clientId 锁拥有的客户端
* @param timeOut 过期时间
*/
public void updateExpireTime(String key, String clientId, long timeOut) {
Jedis jedis = null;
try {
jedis = JedisUtils.getJedis();
if (jedis == null) {
return;
}
// 如果存在 给key设置过期时间 保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";
Object res = jedis.eval(script, Collections.singletonList(key), Arrays.asList(clientId, (timeOut / 1000) + ""));
} finally {
if (jedis != null) {
jedis.close();
}
}
}
/**
* 原子性加锁
*
* @param key 加锁的key
* @param clientId 锁拥有的客户端
* @param expire 过期时间
* @return 加锁是否成功
*/
public boolean lockUnInterrupted(String key, String clientId, long expire) {
Jedis jedis = null;
try {
jedis = JedisUtils.getJedis();
if (jedis != null) {
// set key value nx px timeOut 保证原子性
SetParams setParams = new SetParams();
setParams.nx();
setParams.px(expire);
String res = jedis.set(key, clientId, setParams);
return LOCK_SUCCESS.equals(res);
}
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
/**
* 释放锁
*
* @param key 释放锁key
* @param clientId 锁拥有的客户端
*/
public void unlockUnInterrupted(String key, String clientId) {
Jedis jedis = null;
try {
jedis = JedisUtils.getJedis();
if (jedis == null) {
return;
}
// 如果存在,删除key 保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
Object res = jedis.eval(script, Collections.singletonList(key), Collections.singletonList(clientId));
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
Redisson
在 Java 生态中,虽然可以手写 Lua 脚本实现分布式锁,但生产环境通常推荐使用 Redisson。它已经完美解决了我在上条回答中提到的:原子性、看门狗自动续期、锁重入、以及释放他人锁等所有坑。
以下是基于 Redisson 的完整示例,涵盖了配置、加锁及业务逻辑。
1. 引入依赖 (Maven)
在 pom.xml 中添加 Redisson 依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.21.0</version> </dependency>
2. 完整 Java 示例代码
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;
public class RedisLockDemo {
public static void main(String[] args) {
// 1. 配置 Redisson
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setDatabase(0);
// .setPassword("your_password"); // 如果有密码
// 2. 创建 Redisson 实例
RedissonClient redisson = Redisson.create(config);
// 3. 获取锁实例(注意:此时并未在 Redis 中加锁,只是获取了一个锁对象)
String lockKey = "order_payment_lock_1001";
RLock lock = redisson.getLock(lockKey);
try {
/**
* 4. 尝试加锁
* waitTime: 等待获取锁的最长时间
* leaseTime: 锁自动释放的时间(如果不设置或设为 -1,则启用 Watch Dog 看门狗)
* unit: 时间单位
*/
boolean isLocked = lock.tryLock(10, -1, TimeUnit.SECONDS);
if (isLocked) {
try {
System.out.println("成功获取分布式锁,开始执行核心业务逻辑...");
// 模拟业务处理耗时
Thread.sleep(5000);
System.out.println("业务处理完成。");
} finally {
// 5. 释放锁(必须放在 finally 中,且判断是否还被当前线程持有)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
System.out.println("成功释放锁。");
}
}
} else {
System.err.println("获取锁失败,可能有其他线程正在处理该订单。");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 6. 关闭 Redisson 客户端
redisson.shutdown();
}
}
}
3. 代码核心逻辑解析
-
看门狗 (Watch Dog):
在lock.tryLock(10, -1, TimeUnit.SECONDS)中,第二个参数传-1。这意味着 Redisson 会启动看门狗,每 10 秒(默认lockWatchdogTimeout的 1/3)检查一次,如果业务没跑完,自动给锁续期。这解决了业务执行时间不确定的问题。 -
重入性 (Reentrancy):
Redisson 的锁支持重入,这意味着同一个线程可以多次调用lock()而不会死锁,Redis 内部会通过 Hash 结构维护一个计数器。 -
释放安全:
lock.unlock()内部封装了 Lua 脚本,会自动判断当前线程是否是锁的持有者,避免了“误删他人锁”的尴尬。
4. 生产环境建议
如果是 Spring Boot 项目,建议直接使用 redisson-spring-boot-starter,通过 application.yml 进行配置,并使用 @Autowired 注入 RedissonClient 即可,代码会更加简洁。
时钟回拨
在分布式锁或分布式 ID 生成(如 Snowflake 算法)的场景中,时钟回拨(Clock Skew/Backwards) 是个非常棘手的问题。如果服务器时间突然跳回过去,可能导致已经过期的锁“死灰复燃”,或者生成重复的 ID。
针对分布式锁,处理时钟回拨通常有以下几种策略:
1. 运维层面的规避:使用 NTP 的 step vs slew
最根本的解决办法是在系统层面防止时间大幅度跳变。
- Step 模式:发现时间不对,直接“跳”过去。这会导致时钟回拨,风险极大。
- Slew 模式(平滑同步):发现时间不对,通过微调时钟频率,让时间慢慢“追”上或“等”一下正确时间。每次调整极小(如 0.5ms/s)。
- 建议:在分布式环境下,配置 NTP 服务使用
-x参数(slew mode),禁止大幅度的时间跳变。
2. 算法层面的容错(以 Redisson 为例)
成熟的 Redis 客户端(如 Redisson)在实现分布式锁时,并不完全依赖本地系统时间来判断锁是否到期。
- 内部计时器(Relative Time):Redisson 在处理 TTL(过期时间)时,使用的是
System.nanoTime()这种单调递增的时间戳,而不是System.currentTimeMillis()。 - 原理:
nanoTime返回的是从某个固定但任意的时间点开始的纳秒数,它不受系统时钟调整的影响。即便你手动把系统时间调回 1 小时前,nanoTime依然会持续增加。
3. Redlock 算法中的时钟处理
如果你使用了 Redis 官方推荐的 Redlock 算法,时钟回拨会导致其安全性失效。比如:
- 客户端 A 在 N1, N2, N3 三个节点加锁成功。
- N1 节点发生时钟回拨,导致锁提前过期。
- 客户端 B 再次在 N1 以及之前的 N4, N5 加锁成功。
解决方案:
- 延迟重启:如果一台 Redis 服务器发生了时钟跳变或宕机,不应立即投入使用,而应该等待一个大于“最大锁有效期”的时间后再加入集群。这样可以保证该节点上旧的锁肯定已经全部自然过期。
4. 业务层的“时钟感知”检查
如果你是手写分布式锁或使用 Snowflake ID,可以记录上一次操作的时间戳:
long lastTimestamp = ...;
long currentTimestamp = System.currentTimeMillis();
if (currentTimestamp < lastTimestamp) {
// 发现时钟回拨
long offset = lastTimestamp - currentTimestamp;
if (offset <= 5) {
// 1. 如果回拨时间很短(如 5ms),可以尝试等待
Thread.sleep(offset << 1);
currentTimestamp = System.currentTimeMillis();
} else {
// 2. 如果回拨时间过长,直接抛出异常,拒绝服务
throw new RuntimeException("Clock moved backwards. Refusing to generate ID/Lock.");
}
}
总结
- 最推荐:运维层面强制开启 NTP slew mode,且代码框架选用 Redisson(它通过单调时间解决了大部分本地时钟回拨问题)。
- 最严苛:如果是金融级场景,配合延迟重启策略和回拨异常监控。
RedLock 实现原理
Redlock(红锁)算法由 Redis 的作者 Antirez 提出,旨在解决单机 Redis 或主从架构在发生 Failover(故障转移)时可能导致的分布式锁失效问题。其核心思想是:让客户端向多个独立的 Redis 节点依次请求加锁,只有在超过半数节点成功且耗时在有效范围内时,才认为最终获取锁成功。
1. 红锁算法的前提条件
- 独立节点:必须部署 \(N\) 个(通常 \(N=5\))完全独立的 Redis 实例。这些实例之间不需要主从复制,也不需要哨兵集群,它们是互不通信的孤岛。
- 时钟漂移:算法假设不同机器间的时钟漂移在可控范围内(通常远小于锁的过期时间)。
2. 红锁实现的 5 大步骤
假设我们有 5 个独立的 Redis 实例,客户端获取锁的具体流程如下:
- 获取当前时间戳:
客户端记录当前的毫秒级时间戳 \(T_1\)。 - 依次尝试加锁:
客户端按顺序向 5 个实例发起加锁请求。- 使用相同的
key和唯一的value(如 UUID)。 - 设置超时时间:为了防止某个 Redis 节点宕机导致客户端长时间阻塞,向每个实例请求锁的超时时间应该远小于锁的有效时间(例如锁过期时间 10s,请求超时可设为 5-50ms)。
- 使用相同的
- 计算加锁耗时与成功数:
当尝试完所有节点后,客户端记录当前时间 \(T_2\)。- 总耗时:\(\Delta T = T_2 - T_1\)。
- 成功数:记录有多少个节点返回加锁成功。
- 有效性判断:
只有满足以下两个条件,才认为最终获取锁成功:- 条件 A:客户端成功获取了大多数节点的锁(即 \(\ge N/2 + 1\),5 个节点需成功 3 个)。
- 条件 B:总耗时 \(\Delta T\) 小于锁的有效时间(TTL)。
- 计算锁的最终有效时间:
如果获取锁成功,锁的实际存活时间需要扣除获取锁时的开销。即:\[\text{Final\_TTL} = \text{Initial\_TTL} - \Delta T - \text{Clock\_Drift} \](其中Clock_Drift是为了补偿系统时钟误差而预留的补偿值)。
失败回滚:如果获取锁失败(节点数不足或时间超时),客户端必须立即向所有实例(包括加锁失败的实例)发送释放锁的 Lua 脚本,以防因网络波动导致的“残余锁”占用资源。
3. 红锁解决的核心问题:脑裂与同步延迟
在普通的主从架构中,如果 Master 刚写完锁还没同步给 Slave 就挂了,Slave 变成新 Master 后,锁就丢失了。
Redlock 的逻辑是:即使 5 个节点里挂了 1-2 个,只要剩下 3 个节点还能正常工作且持有锁信息,其他客户端就无法通过“过半数”校验,从而保证了锁的安全性 (Safety)。
4. 业界争议与深度思考
著名的分布式系统专家 Martin Kleppmann 曾发文质疑过 Redlock 的安全性,主要集中在以下几点:
- 时钟跳变风险:如果其中一台机器发生了大幅度的时钟跳变(比如手动调快了 1 分钟),会导致该节点上的锁提前失效。
- 对策:运维层面需严禁
ntpdate强制同步,必须使用平滑步进的slew模式。
- 对策:运维层面需严禁
- 进程停顿(GC Pause):客户端获取锁成功后,如果发生长时间的 Full GC 导致进程卡死,等恢复时锁可能已过期,但客户端仍认为自己持有锁。
- 对策:业务逻辑中引入 Fencing Token(击剑令牌),每次写入数据库时携带一个递增的序列号,数据库端校验序号的合法性。
- 性能开销:相比单机锁,Redlock 需要多次网络往返(RTT),吞吐量会有所下降。
5. Java 中如何快速实现?
不需要手动写循环,Redisson 已经封装好了 RedissonRedLock:
RLock lock1 = redissonInstance1.getLock("lock");
RLock lock2 = redissonInstance2.getLock("lock");
RLock lock3 = redissonInstance3.getLock("lock");
// 创建红锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
// 尝试加锁,最多等待 100 秒,加锁后 10 秒自动解锁
boolean res = redLock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
// 成功获取锁
}
} finally {
redLock.unlock();
}
总结:Redlock 是一种“以空间换安全”的策略。如果你的业务场景对并发安全性要求极高(如金融结算),建议使用 Redlock 或直接转向 Zookeeper/Etc-d 这种基于强一致性协议(CP)的实现。如果是普通高并发场景(如秒杀),单机 Redis 配合 Watch Dog 往往已经足够。
Fencing Token(击剑令牌) 是一种用于解决分布式锁在“长进程停顿(如 Full GC、网络丢包)”导致锁过期后,保护共享资源(如数据库)不被旧请求错误修改的机制。
其核心原理非常简单:全局递增。 每次获取锁时,从服务端(或全局计数器)拿到一个递增的 ID,数据库在更新时检查该 ID 是否比当前记录的 ID 大。
1. 数据库表设计
首先,你的业务表需要一个字段来记录“最后一次操作成功的 Token”。
CREATE TABLE order_info (
order_id BIGINT PRIMARY KEY,
status VARCHAR(20),
-- 记录最后一次成功修改该行记录的 Token
last_fencing_token BIGINT DEFAULT 0
);
2. Java 示例代码
这里结合 Redisson 获取锁,并模拟一个递增的 Fencing Token。
public class FencingTokenService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate redisTemplate; // 用于生成全局递增 Token
@Autowired
private OrderMapper orderMapper;
public void processOrder(Long orderId) {
String lockKey = "lock:order:" + orderId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 1. 获取锁
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
try {
// 2. 获取全局递增的 Fencing Token (可以使用 Redis 的 INCR)
Long currentToken = redisTemplate.opsForValue().increment("fencing:token:generator");
// 执行业务逻辑...
System.out.println("获取锁成功,Token 为: " + currentToken);
// 3. 模拟长停顿 (例如 GC),导致锁在 Redis 端过期
// Thread.sleep(40000);
// 4. 写入数据库时,利用 SQL 检查 Token 有效性
boolean success = updateOrderWithFencing(orderId, "PAID", currentToken);
if (!success) {
// 说明在你停顿期间,已经有 Token 更大的请求完成了修改
System.err.println("拒绝写入:发现更新的 Token,当前请求已失效!");
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private boolean updateOrderWithFencing(Long orderId, String status, Long token) {
/**
* 关键 SQL 逻辑:
* UPDATE order_info
* SET status = #{status}, last_fencing_token = #{token}
* WHERE order_id = #{orderId}
* AND last_fencing_token < #{token} <-- 核心:只接受更大的 Token
*/
int rows = orderMapper.updateWithToken(orderId, status, token);
return rows > 0;
}
}
3. 原理解析图
- 客户端 1 获取锁,拿到 Token: 1。随后发生 Full GC,进程卡死。
- Redis 发现锁过期,将其释放。
- 客户端 2 获取锁,拿到 Token: 2。执行业务,将数据库中的
last_fencing_token更新为 2,并释放锁。 - 客户端 1 恢复运行,尝试更新数据库。它携带的是 Token: 1。
- 数据库校验:由于
WHERE last_fencing_token < 1不成立(当前是 2),更新失败。
4. 为什么不直接用数据库乐观锁(Version)?
其实 Fencing Token 在实现上和“版本号乐观锁”非常相似,但它们关注的维度不同:
- 乐观锁 (Version):关注数据的一致性,防止并发冲突。
- Fencing Token:关注锁的有效性。在分布式系统中,它解决了“即便我拿到了锁,但我怎么确定我现在的操作依然是合法的”这一哲学问题。
5. 注意事项
- Token 的生成:必须是全局单调递增的。可以使用 Redis 的
INCR,也可以使用 Zookeeper 的顺序节点。 - 外部系统:如果你的业务逻辑是发邮件、调用第三方支付,这种“不可回滚”的操作,Fencing Token 无法从物理上阻止(发出去的邮件撤不回),它主要保护的是持久化存储(DB/文件系统)。
这种机制通常只在对数据准确性要求极高(如银行转账、库存结算)的场景下使用。