Redis分布式锁实战:从黑马点评项目解析高并发秒杀优化
1. 秒杀场景下的核心痛点:超卖与一人一单
大家好,我是老张,在互联网公司摸爬滚打十来年,做过不少高并发项目,踩过的坑比吃过的盐都多。今天咱们不聊虚的,就拿一个真实的“黑马点评”项目里的秒杀场景,掰开揉碎了讲讲,当几千上万人同时抢购一张优惠券时,系统是怎么被“压垮”的,我们又该怎么用 Redis分布式锁 这把“手术刀”来精准“治疗”。
想象一下这个场景:平台发布了一张“满100减50”的神券,库存就100张。活动一开始,用户疯狂点击“立即抢购”。最初的代码逻辑很简单:查询库存、减库存、创建订单。但就是这个“简单”逻辑,在高并发下会引发两个致命问题。
第一个是 “超卖”。假设库存只剩1张,但两个请求A和B同时执行到了“查询库存”这一步,它们都看到库存是1,都认为自己可以购买,于是都执行了“库存减1”的操作。结果是什么?库存被扣成了-1,凭空多卖出去一张,这在实际电商里就是重大事故,要么平台自己贴钱,要么引发用户投诉。
第二个是 “一人一单”的并发安全问题。业务要求一个用户只能买一张。你在代码里写了“先查一下这个用户有没有下过单,没有的话再让他下单”。听起来没问题吧?但在高并发下,同一个用户的两次请求可能同时通过这个检查,结果他成功下了两单,规则就被破坏了。
我最早处理这类问题,用的是Java自带的 synchronized 关键字,或者 ReentrantLock。在单机环境下,这确实能解决问题,锁住一个用户ID,确保同一时间只有一个线程为他处理订单。但一旦项目部署到多台服务器上(也就是集群部署),这套就完全失效了。因为Java的锁只对当前JVM进程内的线程有效,服务器A加的锁,服务器B根本感知不到,用户请求被负载均衡到B服务器上,照样可以再下一个单。
所以,我们需要一个在所有服务器之间都能看得见、用得上的锁,这就是分布式锁。而Redis,凭借其高性能、单线程命令执行和丰富的数据结构,成为了实现分布式锁的绝佳选择。接下来,我就带你从最基础的Redis命令开始,一步步搭建一个能在生产环境扛住压力的分布式锁。
2. 从零到一:手写一个Redis分布式锁(初级版)
咱们先别急着用现成的框架,自己动手实现一遍,才能彻底搞懂其中的门道。分布式锁最核心的两个操作就是“加锁”和“解锁”。
加锁的本质,就是在Redis里占一个“坑”。我们约定好,用一个特定的Key来表示一把锁,比如 lock:order:用户ID。谁先成功创建了这个Key,谁就拿到了锁。Redis的 SETNX 命令是专门干这个的:如果Key不存在就设置它。为了防止某个服务器加锁后崩溃导致锁永远无法释放,我们还需要给这个Key设置一个过期时间。
基于这个思路,我们可以创建一个简单的锁类。这里我结合黑马点评项目的代码结构,在 utils 包下创建一个 SimpleRedisLock:
public class SimpleRedisLock implements ILock {
private String name; // 锁的业务名称,如"order"
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 使用SETNX命令尝试加锁,并设置过期时间
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, "1", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); // 避免自动拆箱导致的空指针
}
@Override
public void unlock() {
// 删除Key,即释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
然后在秒杀下单的业务层 VoucherOrderServiceImpl 中,我们就可以用这个锁来包裹“一人一单”的逻辑:
public Result seckillVoucher(Long voucherId) {
// ... 前面的校验:活动时间、库存等 ...
Long userId = UserHolder.getUser().getId();
// 创建锁对象,锁的Key是 "lock:order:" + userId
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
boolean isLock = lock.tryLock(2); // 尝试获取锁,等待2秒超时
if (!isLock) {
return Result.fail("不允许重复下单!");
}
try {
// 获取代理对象,确保事务生效(这里涉及Spring事务的一个坑,后面会讲)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 无论如何,最终都要释放锁
lock.unlock();
}
}
这个初级版本跑起来,确实能解决集群下的“一人一单”问题了。但是,它隐藏着几个非常危险的缺陷,直接用到生产环境,半夜肯定会被报警电话叫醒。
缺陷一:误删他人之锁。假设线程A获取了锁,设置的过期时间是10秒。但是A的业务逻辑比较复杂,执行了15秒。在第10秒时,Redis自动把锁删除了(过期了)。此时线程B看到锁没了,就成功加锁并开始执行业务。刚过5秒,线程A终于执行完了,走到 finally 块里,执行了 unlock() 方法——咔嚓,把线程B刚创建的锁给删了!这下子,后续的线程C又能加锁进来,并发安全又失控了。
缺陷二:锁的过期时间如何设置? 设置短了,容易发生上面的情况。设置长了,万一持有锁的服务器真的宕机了,其他线程就得白白等待很久,系统吞吐量下降。这是一个两难的选择。
要解决第一个问题,核心思路是:锁只能由加锁的人来释放。我们需要在加锁时,给锁的Value设置一个唯一标识(比如UUID + 线程ID),释放锁时,先检查当前锁的Value是不是自己当初设置的那个,如果是,才能删除。
3. 步步为营:分布式锁的三大关键优化
发现了初级版的缺陷,我们立刻着手改进。优化不是一蹴而就的,我把它分成了三个递进的阶段,这正好对应了分布式锁成熟度提升的过程。
3.1 优化一:实现锁的“身份标识”与安全释放
首先,我们给每个锁的Value加上唯一标识。这里我采用 UUID(区分不同JVM进程) + 线程ID(区分同一进程内的不同线程) 的组合,确保全局唯一。
public class SimpleRedisLock implements ILock {
// ... 其他字段 ...
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 1. 先获取当前锁的Value
String lockValue = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 2. 判断是否与自己的标识一致
if (threadId.equals(lockValue)) {
// 3. 一致才删除
stringRedisTemplate.delete(KEY_PREFIX + name);
}
// 不一致则什么都不做(锁可能已过期或被其他线程持有)
}
}
这个改进解决了“误删”问题。但是,仔细看 unlock 方法,它包含了 “获取锁值”、“判断”、“删除” 三个操作。这在并发下依然有风险:假设线程A判断 threadId.equals(lockValue) 为 true,正准备执行删除,此时锁恰好过期了(比如TTL到了),线程B趁机加锁成功。紧接着线程A继续执行删除命令,又把线程B的锁给删了。这是因为“判断”和“删除”是两个独立的Redis命令,不是原子操作。
3.2 优化二:借助Lua脚本保证原子性
要确保多个Redis命令的原子性执行,就必须请出 Lua脚本。Redis会单线程执行整个Lua脚本,期间不会被其他命令打断。我们把“判断锁归属”和“删除锁”的逻辑写成一个Lua脚本。
在项目的 resources 目录下创建 unlock.lua 文件:
-- KEYS[1] 是锁的Key, ARGV[1] 是当前线程的标识
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 如果锁的值与传入的标识相同,则释放锁
return redis.call('del', KEYS[1])
end
-- 否则返回0,表示释放锁失败(或无需释放)
return 0
然后在Java代码中加载并执行这个脚本:
public class SimpleRedisLock implements ILock {
// ... 其他字段 ...
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class); // 脚本返回删除成功条数,为Long类型
}
@Override
public void unlock() {
// 通过执行Lua脚本,原子性地完成“判断”和“删除”
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name), // KEYS 数组
ID_PREFIX + Thread.currentThread().getId() // ARGV 数组
);
}
}
到了这一步,我们已经实现了一个在正确性上相当可靠的分布式锁。它解决了误删问题,并通过原子操作保证了释放锁的安全。但是,它仍然是一个“简单”锁,缺乏一些高级特性,比如 可重入性(同一个线程可以多次获取同一把锁)、锁的自动续期(看门狗机制)、更便捷的API等。而这些,正是成熟的第三方库提供的价值。
3.3 优化三:拥抱Redisson,站在巨人的肩膀上
自己造的轮子能跑,但想跑得又快又稳,还得用专业的赛车。在分布式锁领域,Redisson 就是那辆专业的赛车。它是基于Redis的Java驻内存数据网格客户端,提供了丰富的分布式对象,其中就包括我们心心念念的分布式锁。
引入Redisson后,配置和使用变得异常简单。首先在配置类中创建 RedissonClient Bean:
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 单节点模式,按你的Redis地址配置
config.useSingleServer()
.setAddress("redis://你的redis服务器IP:6379")
.setPassword("你的密码")
.setDatabase(0);
return Redisson.create(config);
}
}
然后,在秒杀业务中,我们就可以把之前的 SimpleRedisLock 替换成 Redisson 的锁:
@Resource
private RedissonClient redissonClient;
public Result seckillVoucher(Long voucherId) {
// ... 前面的校验逻辑 ...
Long userId = UserHolder.getUser().getId();
// 获取锁对象,Redisson的锁名称约定和我们的类似
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁,无参方法会进行重试,直到获取成功或超时
boolean isLock = lock.tryLock();
// 更灵活的方式:指定等待时间、锁的持有时间
// boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
return Result.fail("不允许重复下单!");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
lock.unlock();
}
}
Redisson的优势在哪里?
- 可重入锁:同一个线程可以多次获取同一把锁,锁内部有一个计数器,加减到0才会真正释放。这在复杂调用链里非常有用。
- 看门狗自动续期:如果你使用
lock.tryLock()或指定了锁持有时间,Redisson会启动一个后台线程(看门狗),在业务执行期间,定期(默认每10秒)检查锁是否还被持有,如果是,就自动将锁的过期时间重置为初始时间(默认30秒)。这完美解决了“业务执行时间不确定,锁过期时间难设置”的难题。 - 丰富的锁类型:除了普通的可重入锁,还提供公平锁、联锁、红锁、读写锁等,满足各种复杂场景。
- 高可用与故障转移:Redisson支持主从、哨兵、集群等多种Redis部署模式,底层做了很多容错处理。
用了Redisson之后,分布式锁这部分就可以放心了,我们可以把更多精力放在业务逻辑和整体架构的优化上。
4. 实战黑马点评:秒杀系统的全链路优化
有了可靠的分布式锁,解决了“一人一单”的并发安全问题,但这只是秒杀优化的第一步。一个完整的秒杀系统,需要从入口到数据库进行全链路的性能压榨。在黑马点评项目中,我们进行了一系列的优化,让系统从“能跑”到“飞起来”。
第一步:库存预热与Lua脚本原子扣减 秒杀开始前,我们就把优惠券的库存从数据库加载到Redis中。用户下单时,所有的库存检查、扣减、一人一单判断,都不再访问数据库,而是通过一个复杂的Lua脚本在Redis中原子性地完成。这极大地减少了数据库的并发压力。
-- seckill.lua 脚本核心逻辑
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
-- 1. 判断库存
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1 -- 库存不足
end
-- 2. 判断是否已下单 (使用Set记录已下单用户ID)
if (redis.call('sismember', orderKey, userId) == 1) then
return 2 -- 重复下单
end
-- 3. 扣库存、记录用户
redis.call('decr', stockKey)
redis.call('sadd', orderKey, userId)
-- 4. 将订单信息发送到消息队列,进行异步处理
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0 -- 成功
第二步:订单异步处理与消息队列 Lua脚本执行成功后,订单的创建、库存的最终扣减(从Redis同步回MySQL)等耗时操作,不应该阻塞用户的秒杀请求。我们将订单信息放入Redis Stream消息队列,然后由后台的独立线程池去慢慢消费处理。这样,前端用户的体验就是“几乎瞬间”得到响应,告诉他“抢购成功,订单正在处理中”。
第三步:流量削峰与限流 即使做了异步,如果瞬间涌入的请求太多,消息队列也可能被撑爆。我们还需要在网关层或应用入口做限流,比如用令牌桶或漏桶算法,把超过系统处理能力的请求直接拒绝掉,返回“活动太火爆,请稍后再试”,这比让系统雪崩崩溃要好得多。
经过这一套组合拳优化,黑马点评的秒杀接口性能得到了质的提升。从最初的同步下单、频繁访问数据库,到后来的Redis原子操作、异步处理,QPS(每秒查询率)提升了几十倍。更重要的是,系统变得非常稳定,再也不用担心“超卖”和“一人多单”这种基础问题了。
5. 避坑指南:分布式锁实战中的那些“坑”
技术方案听起来美好,但真到上线时,各种意想不到的“坑”会接踵而至。我结合自己的经验,总结几个最容易踩坑的地方,希望能帮你提前排雷。
第一个大坑:Spring事务与锁的释放顺序
这是我们代码里用 AopContext.currentProxy() 获取代理对象的原因。在 seckillVoucher 方法中,我们加了锁,然后在 try 块里调用 proxy.createVoucherOrder()。为什么?因为 createVoucherOrder 方法上有 @Transactional 注解。Spring的事务是通过AOP代理实现的。如果你直接在 this.createVoucherOrder(),调用的是目标对象的方法,事务注解会失效。更关键的是,事务提交是在方法执行完之后。如果先释放锁,再提交事务,那么其他线程可能在事务提交前就看到旧数据,导致并发问题。虽然我们这个场景下,核心校验已在Redis中完成,但养成“先提交事务,再释放锁”的习惯更安全。
第二个坑:锁的粒度要精细
锁的Key是 lock:order:用户ID,我们只锁住了“同一个用户”,而不是“整个秒杀活动”。这是非常关键的设计。如果锁的Key是 lock:seckill:voucherId,那么整个秒杀活动在任意时刻只能有一个线程下单,性能将惨不忍睹。我们的目标是在保证安全的前提下,最大限度地提高并发度。
第三个坑:异常处理与锁的释放
一定要把 lock.unlock() 放在 finally 块中!无论业务逻辑是成功、失败还是抛出异常,都必须保证锁能被释放。否则就变成了“死锁”,所有后续请求都会被卡住。Redisson的锁在这方面做得很好,但如果你用自己实现的锁,这点务必牢记。
第四个坑:Redis的部署模式与锁的可靠性 我们讨论的都是基于单Redis节点的锁。如果这个Redis主节点宕机了,即使有从节点,在主从切换的瞬间,也可能出现锁丢失(因为主节点的数据可能还没同步到新主节点)。对于金融级等高要求场景,Redisson提供了 RedLock 算法,它需要同时向多个独立的Redis节点申请锁,当在一半以上的节点上加锁成功,才算真正持有锁。但这带来了更高的复杂性和性能损耗,绝大多数业务场景,用单Redis节点+哨兵/集群模式已经足够可靠。
踩过这些坑之后,我对分布式锁的理解才真正深刻起来。它不仅仅是一个技术工具,更是一种设计思想,一种在分布式环境下协调多进程、多线程的契约。从最初的手动挡(自己实现),到后来的自动挡(使用Redisson),工具在升级,但背后对原子性、一致性和性能的权衡思考,是永远不会过时的。
好了,关于Redis分布式锁在黑马点评秒杀场景下的实战解析,就先聊到这里。纸上得来终觉浅,绝知此事要躬行。我强烈建议你,把黑马点评这个项目的代码拉下来,自己从头到尾实现一遍,特别是把分布式锁从初级版到Redisson版的演进过程跑一遍,遇到问题多查资料、多思考。这个过程里收获的,远不止分布式锁这一个知识点,而是解决高并发问题的整套方法论。如果你在实践过程中遇到什么问题,也欢迎随时交流。
更多推荐


所有评论(0)