一. 环境介绍
在仿12306项目中,在高并发抢票时,可以利用令牌大闸,进行库存校验和防止机器人刷票,以此减轻服务器的压力。
二. 校验库存
核心思想
在高并发场景下的余票库存查询,效率较低。因此可以针对某一车次,发布相对应比例的令牌,通过查询令牌是否足够,来校验库存。若令牌为0,则告诉用户余票不足,但此时可能仍然有余票,这种情况可以再手动增加一些令牌;若令牌不为0,则执行售票逻辑,并将令牌减1。
代码示例
/**
* 校验令牌
* @param date
* @param trainCode
* @param memberId
* @return
*/
public boolean validSkToken(Date date, String trainCode, Long memberId){
LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);
// 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高
int updateCount = skTokenMapperCust.decrease(date, trainCode, 1);
if (updateCount > 0){
return true;
}else {
return false;
}
}
@SentinelResource(value = "doConfirm", blockHandler = "doConfirmBlock")
public void doConfirm(ConfirmOrderDoReq req) {
// 校验令牌余量
boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
if (validSkToken) {
LOG.info("令牌校验通过");
} else {
LOG.info("令牌校验不通过");
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
}
// 购票逻辑
}
三. 使用令牌锁防止机器人刷票
核心思想
通过令牌锁来限制某一用户在某一段时间内仅能拿到一次锁,进行只能一次购票,若在某一时间段多次购票操作,则返回异常。
则令牌锁应设计为: 日期+车次+用户ID
代码示例
/**
* 校验令牌
* @param date
* @param trainCode
* @param memberId
* @return
*/
public boolean validSkToken(Date date, String trainCode, Long memberId){
LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);
// 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证
String lockKey = RedisKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId;
Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(setIfAbsent)) {
LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey);
} else {
LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey);
return false;
}
// 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高
int updateCount = skTokenMapperCust.decrease(date, trainCode, 1);
if (updateCount > 0){
return true;
}else {
return false;
}
}
四. 改进——Redis缓存+数据库,减轻数据库压力
问题现象
在高并发抢票环境时,如果每一个请求都去操作数据库,那数据库肯定吃不消,在以下代码段中:
int updateCount = skTokenMapperCust.decrease(date, trainCode, 1);
if (updateCount > 0){
return true;
}else {
return false;
}
我们对每个请求的校验都会去操作数据库,因此很容易出现数据库崩溃问题,因此要加入Redis高速缓存,来减轻数据库压力
核心思想
首次查询,往缓存中存放数据,以后每次查询,先减去缓存中的数据,每过一定次数,再去数据库中更新,减轻数据库压力,伪代码如下:
Object skTokenCount = redisTemplate.opsForValue().get(KEY);
if (skTokenCount != null) {
// 操作缓存数据
if (操作后不满足条件) {
LOG.error("获取令牌失败");
return false;
} else {
LOG.info("获取令牌成功");
// 重置过期时间
redisTemplate.expire(KEY, 60, TimeUnit.SECONDS);
// 每获取5个令牌更新一次数据库
if (count % 5 == 0) {
// 同步数据库
}
return true;
}
} else {
LOG.info("缓存中没有KEY");
//查数据库
// 不需要更新数据库,只要放缓存即可
redisTemplate.opsForValue().set(KEY, String.valueOf(count), 60, TimeUnit.SECONDS);
return true;
}
代码示例
/**
* 校验令牌
* @param date
* @param trainCode
* @param memberId
* @return
*/
public boolean validSkToken(Date date, String trainCode, Long memberId){
LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);
// 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证
String lockKey = RedisKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId;
Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(setIfAbsent)) {
LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey);
} else {
LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey);
return false;
}
String skTokenCountKey = RedisKeyPreEnum.SK_TOKEN_COUNT + "-" + DateUtil.formatDate(date) + "-" + trainCode;
Object skTokenCount = redisTemplate.opsForValue().get(skTokenCountKey);
if (skTokenCount != null) {
LOG.info("缓存中有该车次令牌大闸的key:{}", skTokenCountKey);
Long count = redisTemplate.opsForValue().decrement(skTokenCountKey, 1);
if (count < 0L) {
LOG.error("获取令牌失败:{}", skTokenCountKey);
return false;
} else {
LOG.info("获取令牌后,令牌余数:{}", count);
redisTemplate.expire(skTokenCountKey, 60, TimeUnit.SECONDS);
// 每获取5个令牌更新一次数据库
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 (CollUtil.isEmpty(tokenCountList)) {
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;
}
// 令牌还有余量
// 令牌余数-1
Integer count = skToken.getCount() - 1;
skToken.setCount(count);
LOG.info("将该车次令牌大闸放入缓存中,key: {}, count: {}", skTokenCountKey, count);
// 不需要更新数据库,只要放缓存即可
redisTemplate.opsForValue().set(skTokenCountKey, String.valueOf(count), 60, TimeUnit.SECONDS);
return true;
}
}
最终可以实现,每隔五次操作,redis中数据同步到数据库中,减轻了数据库压力。
遇到的问题
Redis报错:
ERR value is not an integer or out of range. channel: [id: 0xed9c342c, L:/192.168.16.107:55421 - R:r-uf6ljbcdaxobsifyctpd.redis.rds.aliyuncs.com/47.103.172.100:6379] command: (DECRBY), promise: java.util.concurrent.CompletableFuture@7b986dc0[Not completed, 1 dependents], params: [[-84, -19, 0, 5, 116, 0, 30, 83, 75, 95, …], 1]
Redis中存放的value值:
\xac\xed\x00\x05t\x00\x019
应该是9,但是有一堆前缀。同时Key也有类似的前缀
解决方法:
添加RedisConfig.java类
@Component
public class RedisConfig {
@Autowired
private RedisTemplate redisTemplate;
@Autowired(required = false)
public void setRedisTemplate(RedisTemplate redisTemplate) {
RedisSerializer stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);
redisTemplate.setValueSerializer(stringSerializer);
redisTemplate.setHashKeySerializer(stringSerializer);
redisTemplate.setHashValueSerializer(stringSerializer);
this.redisTemplate = redisTemplate;
}
}
将redisTemplate默认改为stringRedis序列化方式,问题解决。