超卖问题
之前完成了核心功能--购票业务,现在需要考虑到并不是一台设备去买票,还要有其他其他的设备一起买票,现在有A~G共七台设备购票1张,现在库存是2,它们同时读到库存是2,可以进行购买,从而进行购票操作,由于票不够卖,那么此时就会发生超卖。那么如何解决呢?可以想到,在一个时间段内,只要有一个线程进入购票界面就行了。
redis分布式锁
首先想到的是在购票方法上加synchronized,它可以解决单机多线程的超卖问题,但是这样子在多节点下还是会发生超卖,以及售票的效率比较低。哪还有什么办法呢?可以想到,对多个节点加上同一把锁,这样子就可以解决超卖的问题了。用数据库可以做分布式锁的,只要所有的节点都能够读到这个库,他就是被所有节点认同的地方,由于MySQL性能不高,所有采用性能更高的redis来做,即可以采用redis的分布式锁。springboot采用了多种方式来操作redis的分布式锁,这里可以使用StringRedisTemplate。即redisTemplate.opsForValue().setIdAbsent(),在 Spring Data Redis 中,opsForValue()是RedisTemplate提供的一个核心方法,用于操作 Redis 的 字符串(String)类型 数据。它返回一个 ValueOperations
对象,该对象封装了对 Redis 字符串类型的所有基础操作。其中的setIdAbsent对于的Redis命令的SET key value NX PX millisecond,是一个原子操作,参数有K,V,过期时间以及时间单位,返回时boolean,根据返回结果可以判断一个线程是否在redis中已存在,程序如果返回失败,就说明拿不到锁,不能够参与抢票。可以将日期和车次作为锁,因为不同时间的不同车次够不成超卖。
String key = req.getDate() + "-" + req.getTrainCode();
Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(key, key, 5, TimeUnit.SECONDS);
if(setIfAbsent) {
LOG.info("抢锁成功");
}else {
LOG.info("抢锁失败");
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
}
当拿到锁之后,到某一步购票失败了,那么这里设置的5s超时时间,这5s内,没有人能够买票,必须等锁消失后,下一个人进来重新拿到锁之后才能抢票,为了解决,可以在程序的结尾删掉锁,但是这里并不能删除掉程序失败的锁,因此可以使用try-catch-finally,无论程序成功或者失败,都能够释放锁。
public void doConfirm(ConfirmOrderDoReq req) {
String lockKey = DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode();
Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
if(setIfAbsent) {
LOG.info("抢锁成功");
}else {
LOG.info("抢锁失败");
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
}
try {
Date date = req.getDate();
String trainCode = req.getTrainCode();
String start = req.getStart();
String end = req.getEnd();
List<ConfirmOrderTicketReq> tickets = req.getTickets();
// 保存确认订单表,状态初始
DateTime now = DateTime.now();
ConfirmOrder confirmOrder = new ConfirmOrder();
confirmOrder.setId(SnowUtil.getSnowflakeNextId());
confirmOrder.setCreateTime(now);
confirmOrder.setUpdateTime(now);
confirmOrder.setMemberId(LoginMemberContext.getId());
confirmOrder.setDate(date);
confirmOrder.setTrainCode(trainCode);
confirmOrder.setStart(start);
confirmOrder.setEnd(end);
confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
confirmOrder.setTickets(JSON.toJSONString(req.getTickets()));
confirmOrderMapper.insert(confirmOrder);
// 查出余票记录,得到真实的库存
DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end);
LOG.info("余票信息:{}", dailyTrainTicket);
// 扣减余票数量,判断余票是否足够
reduceTickets(req, dailyTrainTicket);
// 最终选座结果
List<DailyTrainSeat> finalSeatList = new ArrayList<>();
// 计算相对第一个座位的偏移值
// 比如选择C1,D2,偏移值[0,5]
// 选择A1,B1,C1,偏移值[0,1,2]
// 判断是否有选座
ConfirmOrderTicketReq ticketReq0 = tickets.get(0);
if(StrUtil.isNotBlank(ticketReq0.getSeat())) {
LOG.info("本次购票有选座");
// 查出本次选座的座位类型有那些列,用于计算所选座位与第一座位的偏移值
List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode());
LOG.info("本次选座的座位类型包含的列:{}", colEnumList);
// 组成和前端两排选座一样的列表,用于作参照的座位列表
List<String> referSeatList = new ArrayList<>();
for (int i = 1; i <= 2; i++) {
for (SeatColEnum seatColEnum : colEnumList) {
referSeatList.add(seatColEnum.getCode() + i);
}
}
LOG.info("用于作参照的座位:{}", referSeatList);
List<Integer> offsetList = new ArrayList<>();
// 计算所选座位与第一座位的偏移值
List<Integer> aboluteOffsetList = new ArrayList<>();
for (ConfirmOrderTicketReq ticketReq : tickets) {
int index = referSeatList.indexOf(ticketReq.getSeat());
aboluteOffsetList.add(index);
}
LOG.info("所有座位的绝对偏移值:{}", aboluteOffsetList);
for (Integer index:aboluteOffsetList) {
int offset = index - aboluteOffsetList.get(0);
offsetList.add(offset);
}
LOG.info("所有座位的相对第一个位置的偏移值:{}", offsetList);
getSeat(finalSeatList,
date,trainCode,ticketReq0.getSeatTypeCode(),
ticketReq0.getSeat().split("")[0],offsetList,
dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex());
}else {
LOG.info("本次购票无选座");
for (ConfirmOrderTicketReq ticketReq : tickets) {
getSeat(finalSeatList,
date,trainCode,ticketReq.getSeatTypeCode(),
null,null,
dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex());
}
}
LOG.info("最终选座:{}",finalSeatList);
try {
afterConfirmOrderService.afterDoConfirm(dailyTrainTicket,finalSeatList,tickets,confirmOrder);
}catch (Exception e) {
LOG.error("保存购票信息失败",e);
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION);
}
} finally {
// 释放锁
LOG.info("购票结束,释放锁! lockKey:{}",lockKey);
redisTemplate.delete(lockKey);
}
}
看门狗解决锁失效的问题
redis的分布式锁一定可以解决超卖问题吗?设想一个场景,现在购买票业务流程很多,执行时间比较长,超过了锁时间,这时候就会导致锁失效,从而导致超卖。因此可以开一个守护线程,让守护线程关注锁的超时时间,一旦到达某一时间,守护线程可以重制超时时间,从而防止了锁失效。首先需要注入RedissonClient
String lockKey = DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode();
RLock lock = null;
try {
// 使用redisson,自带看门狗
lock = redissonClient.getLock(lockKey);
/**
waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false
leaseTime – lease time 锁时长,即n秒后自动释放锁
time unit – time unit 时间单位
*/
// boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗
boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗
if (tryLock) {
LOG.info("恭喜,抢到锁了!");
} else {
// 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
LOG.info("很遗憾,没抢到锁");
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
}
// 购票逻辑
}catch (InterruptedException e) {
LOG.error("购票异常", e);
}finally {
LOG.info("购票结束,释放锁!");
if(null != lock && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
当 Redis 主节点宕机且启用主从切换时:
- 场景1:主节点宕机前未将锁同步到从节点
→ 新主节点无锁记录 → 其他客户端可获取相同锁 → 超卖风险 - 场景2:主节点宕机前锁已同步到从节点
→ 新主节点有锁记录 → 但原客户端可能仍在执行业务 → 锁状态混乱
时序
- Client1 在 NodeA 获取锁 Lock1(未同步到 NodeB)
- NodeA 宕机 → 集群切换至 NodeB 为主
- Client2 在 NodeB 获取 Lock1(因 NodeB 无锁记录)
- 此时 Client1 和 Client2 同时持有“相同”锁 → 超卖发生
为了解决这一问题,可以使用RedLock,当N个独立节点获取锁,成功数大于N/2才算成功,但是红锁可能因为时钟漂移导致锁失效,以及网络分区可能产生脑裂。这是可以使用唯一令牌标识锁的持有者,释放锁时严格校验令牌等方法。
令牌大闸防止机器人抢票
为什么引入令牌大闸?由于分布式锁和限流都不能解决机器人刷票问题,1000个人请求抢票,900个限流失败,剩下的100个可能是一个人在抢票。令牌记录用户的信息,如果一个用户拿过令牌,那么几秒钟之内,就不可以再次拿到令牌。当没有余票时,需要查库存才能知道没票,会影响性能,不如令牌余量来的快。令牌不是无限发放的,它与票数以及作为有关。例如有10个站,共100个座位,最多是卖100张票,那么就可以设置1000个令牌。
令牌的初始化
秒杀令牌表的设计,使用日期和车次编号来定义一个令牌,令牌余量指的时某一天某个车次可以生成多少张令牌。令牌一般放到内存里,一个用户进来就会生成一个令牌,然后在redis里面记录令牌余量-1,这种做法不涉及到数据库,所以比较快。但是也可以设计成一张表,可以方便做一些统计,做一些额外的操作。例如增加快照表,记录哪个车次令牌变化的更快,可以看作卖的最快。那么这样子在数据库中进行令牌余量的扣减,不如在内存中,但是可以使用缓存来进行加速。
令牌界面初始化完成之后,首先要进行的是当初始化车次信息时根据车票和座位初始化出正确的令牌数量。对于令牌某天某车次的初始化,为了保证不混乱,首先是要清空要生成天数和车次的令牌数量。之后可以初始化令牌的所有字段,其中令牌的数量是要根据卖票的实际比例来定,买票可以通过整个车次的座位数以及整个车次的到站数来确定,需要按车次查出全部车站以及按车次查出全部座位(不包含座位类型这个参数,因为是为全部的票生成的令牌)
public void genDaily(Date date, String trainCode) {
LOG.info("删除日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode);
SkTokenExample skTokenExample = new SkTokenExample();
skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode);
skTokenMapper.deleteByExample(skTokenExample);
DateTime now = DateTime.now();
SkToken skToken = new SkToken();
skToken.setDate(date);
skToken.setTrainCode(trainCode);
skToken.setId(SnowUtil.getSnowflakeNextId());
skToken.setCreateTime(now);
skToken.setUpdateTime(now);
int seatCount = dailyTrainSeatService.countSeat(date, trainCode);
LOG.info("车次【{}】座位数:{}", trainCode, seatCount);
long stationCount = dailyTrainStationService.countByTrainCode(date, trainCode);
LOG.info("车次【{}】到站数:{}", trainCode, stationCount);
// 3/4需要根据实际卖票比例来定,一趟火车最多可以卖(seatCount * stationCount)张火车票
int count = (int) (seatCount * stationCount * 3/4);
LOG.info("车次【{}】初始生成令牌数:{}", trainCode, count);
skToken.setCount(count);
skTokenMapper.insert(skToken);
}
令牌的使用
令牌初始化完成之后,应该如何去使用?用户提到在购票开始时检查令牌数量,如果没了就提示售罄,避免后续抢锁。令牌数量大约代表可用票数,这样可以减轻服务器压力,尤其是在高并发情况下。首先,是在购票流程开始时,需要校验令牌的数量,如果没有了令牌,那么就可以看作车票没有了,从而接下来的抢座也不必要。如果还有余量,使用数据库操作来扣减余量,SQL如下
<update id="decrease">
update sk_token
set `count` = if (`count` < 1, 0, `count` - 1)
where `date` = #{date}
and train_code = #{trainCode}
and `count` > 0
</update>
注意,有可能令牌数量没有了,但是还有车票,这时候可以让相关的工作人员或者机器监控程序进行令牌数量的添加,这里采用的是这一种方式,因为检验令牌余量比检验车票余量要快。
还有一个实现思路,将令牌当作真实的库存来使用,一个令牌对应一张票,如果一起维护令牌和票的话扣令牌时直接生成票,如果分开维护令牌和票。可以在扣令牌时加速,扣成功后立即在同一事务内生成票。再者也可以进行定时任务,对比总令牌减去剩余令牌和实际的出票数,是否相同,如果不同,就需要进行补票和退款。
防止机器人刷票
根据用户的id进行判断,一段时间内只能有一个用户拿到令牌。注意,这里没有进行锁的释放,当抢到锁之后,将用户锁定5s,防止刷票
RLock lock = null;
try {
lock = redissonClient.getLock(lockKey);
boolean tryLock = lock.tryLock(5, TimeUnit.SECONDS);
if(tryLock) {
LOG.info("获取令牌锁成功!");
// 令牌约等于库存,令牌没有了,就不在卖票了
int updateCount = skTokenMapperCust.decrease(date, trainCode);
if(updateCount > 0) {
return true;
}else {
return false;
}
}else {
LOG.info("获取令牌锁失败!");
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_REPEAT);
}
} catch (InterruptedException e) {
LOG.error("购票异常", e);
}
return false;
当抢票开始时,很多人进来,很多人拿到锁,很多人都能更新数据库,对数据库压力很大,因此可以引入缓存,不是实时的更新数据库,令牌的数量可以通过缓存来进行判断。为了和其他的redis分布式锁的key作区别,可以在不同的功能之间加上前缀,表示不同功能的key。此时可能跟新的令牌数不止一个,因此需要更改mapper层的代码,使其更新令牌余量更加的通用。
首先根据key从缓存种获取令牌,如果有令牌的话,在缓存中将令牌数减1,如果还有令牌的话,每5次更新操作将数据更新到数据库中,为了使缓存长期存在,可以在令牌余量还有是进行缓存重制。如果缓存中没有,直接访问数据库,先从数据库中查剩余的令牌,如果查不到或者没有令牌数了,就返回false,有的话放到缓存中去,进行更新
String skTokenCountKey = RedisKeyPreEnum.SK_TOKEN_COUNT + "-" + DateUtil.formatDate(date) + "-" + trainCode;
RBucket<Object> bucket = redissonClient.getBucket(skTokenCountKey);
Object skTokenCount = bucket.get();
if (skTokenCount != null) {
LOG.info("缓存中有该车次令牌大闸的key:{}", skTokenCountKey);
int count = (int)skTokenCount - 1;
if (count < 0) {
LOG.error("获取令牌失败:{}", skTokenCountKey);
return false;
} else {
LOG.info("获取令牌后,令牌余数:{}", count);
bucket.set(count, 60, TimeUnit.SECONDS);
if (count % 5 == 0) {
skTokenMapperCust.decrease(date, trainCode, 5);
}
return true;
}
} else {
LOG.info("缓存中没有该车次令牌大闸的key:{}", skTokenCountKey);
SkTokenExample skTokenExample = new SkTokenExample();
skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode);
List<SkToken> tokenCountList = skTokenMapper.selectByExample(skTokenExample);
if (tokenCountList.isEmpty()) {
LOG.info("找不到日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode);
return false;
}
SkToken skToken = tokenCountList.get(0);
if (skToken.getCount() <= 0) {
LOG.info("日期【{}】车次【{}】的令牌余量为0", DateUtil.formatDate(date), trainCode);
return false;
}
Integer count = skToken.getCount() - 1;
skToken.setCount(count);
LOG.info("将该车次令牌大闸放入缓存中,key: {}, count: {}", skTokenCountKey, count);
bucket.set(count, 60, TimeUnit.SECONDS);
return true;
}
最后可以在购票的界面增加验证码进行机器人的拦截。对于验证码的接口方法,首先生成验证码的字符串,然后将生成的验证码放到redis缓存中,后续验证是用到。对于放入缓存的set,需要增加一个imageCodeToken,用来识别用户。前端生成唯一的token,每次都不同,以token为key,验证码的字符为value,放入redis中。