1. 为什么我们需要分布式锁和异步秒杀?
大家好,我是老王,一个在互联网大厂摸爬滚打了十年的老码农。这些年,我参与过好几个电商大促项目,最让人头疼的就是“秒杀”。想象一下,你精心策划了一场活动,准备了1000件爆款商品,结果开抢瞬间涌进来10万人。如果系统没设计好,轻则页面卡死、用户骂娘,重则库存超卖、订单错乱,甚至数据库直接被打挂,那场面,简直是技术人的噩梦。
最开始,我们也是用最朴素的方法:在Java代码里加个 synchronized 或者 ReentrantLock。这在单台服务器上跑得好好的,大家排队下单,井然有序。但问题来了,现在的应用哪有单机的?为了扛住流量,我们肯定得部署多台服务器,搞成集群。这时候,synchronized 就彻底失效了,因为它只能锁住自己JVM进程里的线程,管不了其他服务器上的“兄弟”。A服务器觉得用户张三没下单,B服务器也觉得张三没下单,结果张三在两边都成功创建了订单,这就是典型的“一人多单”问题。
所以,我们需要一把在多个服务器、多个进程之间都能看到的“全局锁”,这就是分布式锁。它的核心目标就一个:在分布式系统里,确保同一时间,只有一个“线程”(可能是来自不同机器的)能执行某段关键代码,比如“检查库存并扣减”。
而异步秒杀,则是为了解决性能瓶颈。传统的同步秒杀流程是:用户请求过来 -> 查数据库校验资格 -> 扣减库存 -> 创建订单 -> 返回结果。每一步都卡着用户等,尤其是后两步写数据库,比较慢。10万请求同时卡在数据库写订单上,数据库不崩才怪。异步秒杀的思路就很巧妙:我们把最耗时的“落库”操作往后挪。先快速完成资格校验(这个可以放在更快的Redis里),只要校验通过,立马告诉用户“抢购成功,正在处理中”,然后把生成订单这个“体力活”丢到一个消息队列里,让后台线程慢慢去消化。这样,前端响应极快,用户体验好,后台压力也平缓了。
接下来,我就结合实战,带你一步步优化分布式锁,并设计一个高可用的异步秒杀架构。咱们先从最基础的Redis分布式锁说起。
2. 从入门到放弃:手写一个Redis分布式锁的踩坑之旅
刚开始用Redis做分布式锁,想法很简单:Redis有个 SETNX 命令(SET if Not eXists),只有key不存在时才能设置成功,这不就是天然的锁吗?抢到就是拿到锁。于是,第一版代码诞生了:
// 伪代码:V1.0 天真版
public boolean tryLock(String key) {
return redisTemplate.opsForValue().setIfAbsent(key, "1");
}
public void unlock(String key) {
redisTemplate.delete(key);
}
用起来似乎没问题,但很快就掉坑里了。
2.1 坑一:锁忘了释放,系统永久死锁
如果线程拿到锁后,在处理业务逻辑时抛异常了,或者服务器突然重启,那这个锁就永远留在Redis里了,其他线程再也拿不到锁,业务直接瘫痪。所以,我们必须给锁加一个过期时间。
// 伪代码:V2.0 加过期时间版
public boolean tryLock(String key) {
// 设置锁,并指定10秒后自动过期
return redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
}
2.2 坑二:锁被误删,安全防线崩塌
加了过期时间,心里踏实了点。但新问题又来了:假设线程A拿到锁,设置了10秒过期,但它业务复杂,执行了15秒。10秒一到,Redis自动把锁删了。此时线程B趁虚而入,成功设置了锁。2秒后,线程A终于执行完了,它依然会执行删除锁的操作,结果把线程B刚创建的锁给删了!线程C一看锁没了,也冲进来… 并发问题再次出现。
问题的根源在于,锁的“持有者”和“释放者”不是同一个了。解决方案是:给每个锁的持有者一个唯一标识(比如UUID+线程ID),删除前先核对一下是不是自己的锁。
// 伪代码:V3.0 防误删版
public boolean tryLock(String key) {
String threadId = UUID.randomUUID().toString() + Thread.currentThread().getId();
return redisTemplate.opsForValue().setIfAbsent(key, threadId, 10, TimeUnit.SECONDS);
}
public void unlock(String key) {
String threadId = UUID.randomUUID().toString() + Thread.currentThread().getId();
String currentValue = redisTemplate.opsForValue().get(key);
// 判断当前锁是不是自己持有的
if (threadId.equals(currentValue)) {
redisTemplate.delete(key);
}
}
2.3 坑三:判断与删除不是“原子操作”
V3.0看起来完美了?不,还有一个致命漏洞。get 和 delete 是两个独立的Redis命令,不是原子性的!考虑这个时序:
- 线程A执行
get(key),拿到值value_A,准备删除。 - 就在此时,锁过期了!
- 线程B迅速设置新锁,值为
value_B。 - 线程A继续执行
delete(key),成功删除。但它删除的已经是线程B的锁了!
这个漏洞发生的概率比想象中高,在高并发下就是定时炸弹。要解决这个问题,必须让“判断锁归属”和“删除锁”这两个操作变成一个不可分割的原子操作。Redis事务(multi/exec)做不到,因为它只是把命令打包,执行时还是一个个来,中间可能被其他客户端命令插入。这时候,就需要请出我们的终极武器:Lua脚本。
3. Lua脚本:让Redis命令“原子”执行的瑞士军刀
Lua脚本在Redis中执行时,会被当作一个单命令,在执行期间不会被其他命令打断,从而完美实现原子性。我们来写一个安全的解锁脚本。
首先,在项目的 resources 目录下创建一个 unlock.lua 文件:
-- unlock.lua
-- KEYS[1] 是锁的key
-- ARGV[1] 是当前线程持有的标识
-- 判断锁是否存在,且值是否匹配
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 匹配则删除锁


1523

被折叠的 条评论
为什么被折叠?



