基于 Redis 的秒杀集群系统迭代实现【SETNX】(附详细流程图)
一、前置内容
1.1 单机秒杀实现
基于 Redis 的秒杀单机系统迭代实现(附详细流程图)-CSDN博客
1.2 前置流程图

1.4 前置问题
不同服务器的锁对象是不同的,仍然出现一人多单问题,所以要用到分布式锁,不同的服务器获得同一把锁。
二、分布式锁解决集群模式下一人多单问题
2.1 流程图
由于多个服务器只有一个redis,所以采用redis分布式锁可以实现集群模式下的秒杀
这里采用了redis分布式锁替代了原来的用户悲观锁,并且不管用户有没有抢到,都会在最后删除这个分布式锁,还设置了过期时间防止发生死锁。

2.2 代码实现
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private CacheClient cacheClient;
@Override
@Transactional
public Long seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
throw new BaseException(ExceptionConstants.NOT_START);
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
throw new BaseException(ExceptionConstants.IS_END);
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
throw new BaseException(ExceptionConstants.NOT_ENOUGH);
}
Long userId = UserHolder.getUser().getId();
String key = RedisConstants.getLockOrderKey(userId);
boolean isLock = cacheClient.tryLock(key , RedisConstants.LOCK_ORDER_TTL);
if (!isLock) {
// 获取锁失败,返回错误或重试
throw new BaseException(ExceptionConstants.EXIST_BUY);
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
cacheClient.unlock(key);
}
}
@Transactional
@Override
public Long createVoucherOrder(Long voucherId) {
// 5.一人一单逻辑
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
throw new BaseException(ExceptionConstants.EXIST_BUY);
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
//扣减库存
throw new BaseException(ExceptionConstants.NOT_ENOUGH);
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return orderId;
}
}
public boolean tryLock(String key, Long time) {
// 获取线程标示
long threadId = Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, threadId+"", time, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
public void unlock(String key) {
stringRedisTemplate.delete(key);
}
2.3 问题分析

由于我们给分布式锁加了过期时间,就可能导致时间过长业务还没有结束但是锁已经被释放了,当这个用户再发送了一个请求加上锁时,这个锁又被原来的线程释放了,出现了误删问题。
所以我们在释放锁的时候要注意是不是原来的锁,不能删除别人的锁。
三、解决误删问题
3.1 流程图
线程id是由jvm内部递增获得的,如果有多个服务器,线程的id就会重复,所以要采用更唯一的存储value

3.2 代码实现
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(String key, Long time) {
String value = ID_PREFIX + Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key , value , time, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
@Override
public void unlock(String key) {
String value = ID_PREFIX + Thread.currentThread().getId();
String currentValue = stringRedisTemplate.opsForValue().get(key);
if (value.equals(currentValue)) {
stringRedisTemplate.delete(key);
}
}
3.3 问题分析
因为查询和删除没有写在一条语句,没有保证原子性,所以还是有误删的可能

四、lua脚本解决redis原子性问题
4.1 lua脚本
Redis 本身是单线程执行命令,而Lua 脚本在 Redis 中执行时是原子性的
Lua 脚本中操作 Redis,必须通过 Redis 提供的内置全局函数 redis.call() 或 redis.pcall(),二者功能一致,唯一区别:
redis.call():执行 Redis 命令出错时,直接抛出异常,脚本终止执行,Redis 向客户端返回错误信息;redis.pcall():执行 Redis 命令出错时,捕获异常并返回错误信息,脚本继续执行(推荐生产使用,容错性更高)。
语法:redis.call('Redis命令', '参数1', '参数2', ...)
示例:redis.call('SET', 'name', 'zhangsan')、redis.call('GET', 'name')
4.2 redis中调用lua脚本
EVAL lua_script numkeys [key1 key2 ...] [arg1 arg2 ...]
参数说明(必须严格按顺序传参,Redis 通过numkeys区分 keys 和 args):
lua_script:Lua 脚本的字符串内容;numkeys:后续传入的key 的数量(整数,无 key 则传 0);[key1 key2 ...]:需要在 Lua 脚本中操作的 Redis 键名(可选,通过KEYS表访问);[arg1 arg2 ...]:向 Lua 脚本传递的普通参数(可选,通过ARGV表访问)。-
Redis 为 Lua 脚本内置了两个全局只读表,用于接收外部传入的参数,必须大写:
-
KEYS[i]:访问第 i 个传入的 key(i 从 1 开始,不是 0!Lua 表支持 1 开始索引) -
ARGV[i]:访问第 i 个传入的普通参数(i 从 1 开始)。
-
4.3 编写lua脚本,确保锁判断和释放一起操作
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
4.4 代码实现
下载Emmylua插件


private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock(String key) {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(key),
ID_PREFIX + Thread.currentThread().getId());
}
脚本在初始化的时候读取,这样就不会用一次读取一次了。
4.5 问题分析
- 当前锁可能会出现不可重入问题和死锁问题,并且获取锁失败了就没有重试机会。
- 如果锁是超时释放的,那么业务也会受到影响。
- 当我们执行写操作时,访问主节点,执行读操作,访问从节点,主节点要把数据同步给从节点,如果还没有同步的时候,主节点出现了问题,导致数据未同步,选取的新的从节点作为主节点,数据发生了丢失(概率低)
五、后续内容
5.1 基于Redisson锁实现
基于 Redis 的秒杀集群系统迭代实现【Redisson源码分析】(附详细流程图)-CSDN博客
5.2 基于消息队列异步优化
更多推荐



所有评论(0)