redis 分布式锁(建议收藏)
💡一则或许对你有用的小广告
欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...
,点击查看项目介绍 ;- 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;
截止目前, 星球 内专栏累计输出 82w+ 字,讲解图 3441+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 2900+ 小伙伴加入学习 ,欢迎点击围观
在分布式系统中,多个服务节点可能同时访问共享资源,例如同一商品库存或数据库记录。这种情况下,如果没有协调机制,就可能导致数据竞争、重复操作或资源争用问题。例如,用户在电商平台抢购限量商品时,若多个请求同时减少库存,极可能导致库存值变为负数。为解决此类问题,Redis 分布式锁因其高性能和易用性,成为分布式系统中广泛采用的解决方案。本文将从基础概念、实现原理到实际案例,逐步解析这一技术的核心逻辑。
分布式锁的核心概念与必要性
什么是分布式锁?
分布式锁是分布式系统中用于协调多个节点对共享资源访问的机制,其作用类似于单机环境中的互斥锁。但与单机锁不同,分布式锁需要满足以下特性:
- 互斥性:同一时间只有一个节点能持有锁。
- 可重入性:同一节点在持有锁期间可重复获取锁(可选特性)。
- 故障恢复:当锁的持有者崩溃时,锁能被自动释放,避免资源永久锁定。
为什么需要分布式锁?
在单机环境中,Java的synchronized
或ReentrantLock
可以轻松实现线程间的同步。但在分布式场景下,多个服务实例可能运行在不同物理机上,单机锁无法跨进程或跨机器生效。例如,当三个微服务实例同时尝试更新同一个数据库记录时,若没有分布式锁,数据库可能被多次更新,导致数据不一致。
分布式锁的常见场景
- 秒杀系统:限制同一商品库存被多次扣减。
- 分布式任务调度:确保同一时间只有一个节点执行定时任务。
- 分布式缓存更新:避免多个节点同时刷新缓存,造成资源浪费。
Redis 分布式锁的实现原理
Redis 实现分布式锁的核心命令
Redis 提供了以下命令组合来实现分布式锁:
SETNX
(Set Not eXists):若键不存在,则设置键值对并返回1
,否则返回0
。EXPIRE
:为键设置过期时间,避免锁因持有者崩溃而永久占用资源。- Lua 脚本:将多个命令原子化执行,确保操作的不可中断性。
基础实现逻辑(伪代码)
if SETNX(key, value) == 1:
EXPIRE(key, expire_time)
return True
else:
return False
上述逻辑存在两个问题:
- 分步操作的原子性问题:
SETNX
和EXPIRE
是两个独立命令,若在执行中间发生网络延迟或崩溃,可能导致锁未设置过期时间,引发“死锁”。 - 随机值验证:若锁的持有者崩溃后,其他节点可能过早获取锁,但若未验证当前锁是否属于自己,可能误释放其他进程的锁。
基于 Lua 脚本的原子性优化
通过 Lua 脚本将SETNX
和EXPIRE
合并为原子操作:
local result = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', tonumber(ARGV[2]))
return result
调用示例(Java代码):
String lockKey = "product:1001:lock";
String lockValue = UUID.randomUUID().toString();
long expireTime = 10; // 单位秒
// 使用 Lua 脚本实现原子操作
String script = "if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) == 'OK' then return 1 else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue, String.valueOf(expireTime));
关键设计要素解析
- 随机值(Lock Value):
每个请求生成唯一值(如UUID),在释放锁时,仅当当前值与该随机值一致时才执行删除操作。这避免了误删其他进程的锁。if GET(key) == my_value: DEL(key)
- 过期时间(Expire):
即使持有者崩溃,锁也会在过期时间后自动释放,防止“死锁”。但需注意:- 过期时间应略大于业务操作的最长时间,否则可能因正常执行未完成而提前释放锁。
- Redis 的过期时间是近似值,存在一定的误差(通常在毫秒级)。
分布式锁的代码实现与常见问题
完整代码示例(Java)
以下是一个完整的分布式锁实现类:
public class RedisDistributedLock {
private final RedisTemplate<String, String> redisTemplate;
private final String lockKey;
private final long expireTime;
private final String lockValue;
public RedisDistributedLock(RedisTemplate<String, String> redisTemplate, String lockKey, long expireTime) {
this.redisTemplate = redisTemplate;
this.lockKey = lockKey;
this.expireTime = expireTime;
this.lockValue = UUID.randomUUID().toString();
}
public boolean acquire() {
String script = "if redis.call('GET', KEYS[1]) == false then "
+ "return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', tonumber(ARGV[2])) "
+ "else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = (Long) redisTemplate.execute(
redisScript,
Collections.singletonList(lockKey),
lockValue,
String.valueOf(expireTime)
);
return result != null && result == 1L;
}
public void release() {
String script = "if redis.call('GET', KEYS[1]) == ARGV[1] "
+ "then return redis.call('DEL', KEYS[1]) else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
}
常见问题与解决方案
问题 1:锁的“误释放”
现象:进程 A 获取锁后,因网络延迟未收到响应,此时进程 B 获取锁并释放,导致进程 A 的锁被误删。
解决:
- 使用随机值校验,仅当锁的值与当前进程的值一致时才删除。
- 在释放锁时,使用 Lua 脚本确保原子性:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end
问题 2:锁的“饥饿问题”
现象:当多个客户端持续竞争锁时,某些客户端可能因“永远未获得锁”而阻塞。
解决:
- 在
acquire
方法中添加重试机制,设置最大重试次数或超时时间。 - 使用 Redis 的
SET
命令的PX
(毫秒级过期)参数,缩短重试间隔。
问题 3:Redis 主从同步延迟导致的锁失效
现象:主节点设置的锁因主从同步延迟未生效,导致从节点提前释放锁。
解决:
- 使用 Redis Cluster 或哨兵模式,确保高可用性。
- 在客户端代码中,通过
READONLY
命令强制从主节点读取数据。
实际案例:电商秒杀系统的库存控制
场景描述
某电商平台在双十一活动中,一款手机库存为 100 件。用户通过秒杀页面提交订单时,需确保库存不被超额扣减。
实现方案
- 锁的粒度设计:按商品ID设置锁,如
product:1001:lock
。 - 业务流程:
- 客户端尝试获取锁。
- 成功后,查询数据库库存,若库存>0则扣减并生成订单。
- 释放锁。
代码示例(简化版)
public class SeckillService {
private final RedisDistributedLock lock;
private final ProductRepository productRepo;
public SeckillService(ProductRepository productRepo) {
this.productRepo = productRepo;
this.lock = new RedisDistributedLock(
redisTemplate,
"product:1001:lock",
10 // 秒
);
}
public boolean placeOrder() {
if (!lock.acquire()) {
throw new RuntimeException("获取锁失败");
}
try {
Product product = productRepo.findById(1001);
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productRepo.save(product);
return true;
} else {
return false;
}
} finally {
lock.release();
}
}
}
优化点
- 异步扣减库存:将库存扣减操作改为异步,通过消息队列(如 RabbitMQ)处理,减少锁的持有时间。
- 缓存预热:将库存信息缓存到 Redis,减少数据库压力。
总结
Redis 分布式锁通过原子命令和 Lua 脚本,提供了一种轻量、高效且可靠的分布式协调方案。其核心在于:
- 原子性:确保锁的获取与过期设置同时完成。
- 容错性:通过随机值和过期时间,避免死锁和误删。
- 灵活性:适用于秒杀、任务调度等高频场景。
对于开发者而言,需注意以下要点:
- 避免锁的粒度过细(如按订单ID锁),导致性能下降。
- 设置合理的过期时间,需略长于业务操作耗时。
- 结合重试机制与超时控制,平衡系统可用性与一致性。
随着分布式系统复杂度的增加,Redis 分布式锁仍是开发者应对并发问题的首选工具之一。通过本文的讲解,希望读者能掌握其实现原理,并在实际项目中灵活应用。