Spring MVC + Redis实现激活码秒杀活动(4.性能优化极致的Version3)

在这个版本中,进一步优化统计接口,将redis中的计数用新建异步线程完成,避免和主线程竞争redis资源;同时在前台加上定时器,方便时间验证直接从实例内存里获取。
核心代码 SeckillService
基于访问的统计(一次请求参与人数+1)
@Service public class SeckillService { private static final ExecutorService executorService = new ThreadPoolExecutor(1, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()); private boolean isRemainCode = true;
@Resource private CodeService codeService; @Resource private SeckillRecordDao seckillRecordDao; @Resource private LoadRoundService loadRoundService; @Resource private SeckillCacheDao seckillCacheDao;
public void setIsRemainCode(boolean flag) { this.isRemainCode = flag; }
public SeckillResponse seckill(String urs) { SeckillResponse seckillResponse = new SeckillResponse(); Object startTime = loadRoundService.getRound(); if (startTime == null || !isLegalTime(startTime.toString())) { seckillResponse.setStatus(SeckillBusinessProtocol.TIME_ILLEGAL); return seckillResponse; } String ursInCache = seckillCacheDao.get(urs); if (!StringUtils.isEmpty(ursInCache)) { executorService.execute( new SeckillRecordTask(ursInCache, Long.valueOf(startTime.toString()), seckillRecordDao)); seckillResponse.setStatus(SeckillBusinessProtocol.DUPLICATED_CODE); return seckillResponse; } String status = codeService.hget(SeckillConsts.WIN_CODE, urs); if (!StringUtils.isEmpty(status)) { executorService.execute( new SeckillRecordTask(status, Long.valueOf(startTime.toString()), seckillRecordDao)); seckillResponse.setStatus(SeckillBusinessProtocol.DUPLICATED_CODE); return seckillResponse; } if (codeService.isWhiteUser(urs)) { executorService.execute(new SeckillRecordTask(SeckillConsts.WHITE_USER, Long.valueOf(startTime.toString()), seckillRecordDao)); return seckill(urs, SeckillConsts.WHITE_USER, seckillResponse); } else if (codeService.isBlackUser(urs)) { executorService.execute(new SeckillRecordTask(SeckillConsts.BLACK_USER, Long.valueOf(startTime.toString()), seckillRecordDao)); seckillResponse.setStatus(SeckillBusinessProtocol.DUPLICATED_CODE); seckillResponse.setStatus(SeckillBusinessProtocol.RANK_BLACK_PARTICIPATED); return seckillResponse; } else { executorService.execute(new SeckillRecordTask(SeckillConsts.NORMAL_USER, Long.valueOf(startTime.toString()), seckillRecordDao)); return seckill(urs, SeckillConsts.NORMAL_USER, seckillResponse); } }
private SeckillResponse seckill(String urs, String userRank, SeckillResponse seckillResponse) { if (isRemainCode) { String code = codeService.getCode(SeckillConsts.CODE_LIST + userRank); if (code != null) { if (codeService.setCodeWinner(SeckillConsts.WIN_CODE + userRank, urs, code)) { seckillCacheDao.put(urs, userRank); seckillResponse.setStatus(SeckillBusinessProtocol.GET_CODE); seckillResponse.setCode(code); return seckillResponse; } else { codeService.put(SeckillConsts.CODE_LIST + userRank, code); isRemainCode = true; seckillResponse.setStatus(SeckillBusinessProtocol.DUPLICATED_CODE); return seckillResponse; } } } isRemainCode = false; seckillResponse.setStatus(SeckillBusinessProtocol.FAIL_CODE); return seckillResponse; }
|
SeckillRecordTask :
public class SeckillRecordTask implements Runnable { private String rank; private long currentRound; private SeckillRecordDao seckillRecordDao;
public SeckillRecordTask(String rank, long currentRound, SeckillRecordDao seckillRecordDao) { this.rank = rank; this.currentRound = currentRound; this.seckillRecordDao = seckillRecordDao; }
@Override public void run() { seckillRecordDao.increaseParticipate(this.rank, this.currentRound); } }
|
看一下具体的任务创建的方法,由于用了new每次新建一个任务,所以这里不能再通过Spring注入的方式完成依赖的注入,因为用new了之后就不再归Spring管理,组件涉及到的所有的注入都会失效(直接报NPE),两种解决方法,1.直接把任务设定成单例,这样也可以避免每次都新创建一个任务,增加GC的工作;2.将要注入的Dao,直接作为形参传入,我用了第二种实现方式,其实感觉语义会有点奇怪。
上述实现的逻辑还是基于访问次数的统计,后将逻辑改成按账号统计,故不再需要线程池,核心代码如下:
@Service public class SeckillService { private boolean isRemainCode = true;
@Resource private CodeService codeService; @Resource private LoadRoundService loadRoundService; @Resource private SeckillCacheDao seckillCacheDao;
public void setIsRemainCode(boolean flag) { this.isRemainCode = flag; }
public SeckillResponse seckill(String urs) { SeckillResponse seckillResponse = new SeckillResponse(); Object startTime = loadRoundService.getRound(); if (startTime == null || !isLegalTime(startTime.toString())) { seckillResponse.setStatus(SeckillBusinessProtocol.TIME_ILLEGAL); return seckillResponse; } String ursInCache = seckillCacheDao.get(urs); if (!StringUtils.isEmpty(ursInCache)) { seckillResponse.setStatus(SeckillBusinessProtocol.DUPLICATED_CODE); return seckillResponse; } String ursInRedis = codeService.getUrsInRedis(SeckillConsts.WIN_CODE, urs); if (!StringUtils.isEmpty(ursInRedis)) { seckillResponse.setStatus(SeckillBusinessProtocol.DUPLICATED_CODE); return seckillResponse; } if (codeService.isBlackUser(urs)) { codeService.setUrsFailure(SeckillConsts.FAIL_CODE_BLACK_USER, urs); seckillResponse.setStatus(SeckillBusinessProtocol.RANK_BLACK_PARTICIPATED); return seckillResponse; } else if (codeService.isWhiteUser(urs)) { return seckill(urs, SeckillConsts.WHITE_USER, seckillResponse); } else { return seckill(urs, SeckillConsts.NORMAL_USER, seckillResponse); } }
private SeckillResponse seckill(String urs, String userRank, SeckillResponse seckillResponse) { if (isRemainCode) { String code = codeService.getCode(SeckillConsts.CODE_LIST + userRank); if (code != null) { if (codeService.setUrsSuccess(SeckillConsts.WIN_CODE + userRank, urs, code) > 0) { seckillCacheDao.put(urs, userRank); seckillResponse.setStatus(SeckillBusinessProtocol.GET_CODE); seckillResponse.setCode(code); return seckillResponse; } else { codeService.put(SeckillConsts.CODE_LIST + userRank, code); isRemainCode = true; seckillResponse.setStatus(SeckillBusinessProtocol.DUPLICATED_CODE); return seckillResponse; } } } isRemainCode = false; codeService.setUrsFailure(SeckillConsts.FAIL_CODE + userRank, urs); seckillResponse.setStatus(SeckillBusinessProtocol.FAIL_CODE); return seckillResponse; }
|
后台定时器
这里换了种timer的实现,直接用Spring的timer:
@Scheduled(cron = "0/5 * * * * ?") public void initTimer() { try { if (!StringUtils.isEmpty(jedisDao.lock( SeckillConsts.REDIS_PREFIX, SeckillConsts.LOCK, SeckillConsts.EXPIRE))) { nextRound = seckillConfigDao.getNextRound(); boolean isBegin = isBeginTime(); if (nextRound != null && isBegin) { seckillRedisService.initRedis(nextRound); currentRound = nextRound; } else if (!isBegin && currentRound != null && isEndTime()) { persistService.persistToDb(currentRound); currentRound = null; } jedisDao.unlock(SeckillConsts.REDIS_PREFIX); } } catch (RuntimeException e) { } }
|
可以看见上面的代码比之前简介了很多,一是直接用Spring管理定时器,省去了我们自己管理的操作,二是将之前的两个标志位(isEnd 和isBegin,其实之前一直只需要一个标志就好了,二者是互斥的),通过redis加分布式锁的方式优化掉了,语义更简洁
前台定时器
在这里最简单粗暴的操作就是直接复制后台的定时器,我比较作的又换了种方法实现:即通过后台跳转前台的url实现定时的操作
@Value(value = "${seckill.basePath}") private String basePath; @Value(value = "${seckill.url}") private String url;
private void frontendFoward() { for (int i = 0;i < REPEAT;i++) { String response = HttpClient4Utils.httpPost(basePath + url, null); if (StringUtils.isEmpty(response)) { LOGGER.error("Frontend forward failed, url: {}", basePath + url); } } }
|
用完发现其实还是直接用定时器更好,这样写有几个地方要考虑:
- 分布式环境下,前台做了负载均衡则这边要慎重使用,因为url跳转一次代表一个请求发出,只能被一台前台 接收,固此处我重复将该请求发了REPEAT次,确保每台实例都收到请求(不推荐)
- 需考虑网络阻塞导致失败的情况
- 需在前台Controller中做好权限验证
小结
总结一下做的几个优化:
- 黑白用户名单可以事先导入内存,节省确认用户身份时间
- 生成的激活码也可以在活动开始前生成并导入redis
- 已抢码用户在每场活动结束后,同步至本实例内存中,以加快查重
- 统计功能用异步线程实现,避免争抢redis资源
- 新增isRemainCode标签,用于在激活码已抢完时直接返回
- 前台获取时间直接从内存中读取(由前台定时器直接控制该变量的改变)
- 更换redis的连接池配置(后来用了三种不同的连接池方式测试,见 源码解析:探究JedisPool与CommonRedis的性能差异)
- redis优化,直接用原生jedis速度最快,关闭了testOnBorrow/testOnReturn
- JVM调优:java -Xmx3550m -Xms3550m -Xmn2g -Xss128k(128K可能会报StackOverflow)
最坏情况下(用户抢码成功):检查时间(内存级)+ 在内存中确认是否重复(内存)+ 内存中没有继续在redis中查重(redis)+ 确认用户身份(内存) + 检查是否有码(内存) + lpop激活码(redis) + 信息写回redis(写回redis) = 3次redis操作 + 若干内存级操作 + 0 次db操作
最坏情况下(用户抢码失败):2次redis操作(即少了lpop激活码一次访问redis) + 若干内存级操作 + 0 次db操作
这个版本的QPS已成功突破3000,线上服务器的峰值QPS测试在3600左右,可以说性能已经达到了目前机器的上限

其实还要优化的话,可以从批处理的角度去考虑,即一次导出100个码到内存中,成功/失败抢码的写回也是100个才写一次,这个做法牺牲了部分的可靠性但节省的是成百倍的中间件性能,根据需求考虑是否使用。
这部分的思考见上一篇系列博文《Spring MVC + Redis实现激活码秒杀活动(3.通过lpop操作实现并发控制的Version2)》