高并发秒杀功能架构设计

  • 秒杀场景简述及分析
  • 使用乐观锁控制库存数量
  • 结合redis缓存层减小DB压力
  • 使用zookeeper分布式锁控制库存数量
  • kafka异步削峰
  • 接口限流
  • jmeter压测接口

前阵子经常开发一些秒杀类型的项目,故而抽时间总结下。把我们产品的流程图大致勾勒了下:

项目中的秒杀逻辑简图

 秒杀一类项目有一些公共的特点:

  • 秒杀开始是并发流量瞬间增大;
  • 秒杀的奖品库存一般不多,真正秒杀成功到的比较少;
  • 业务流程相对简单

应该着重考虑的点:

  • 确保商品不卖超,10件库存就严格控制最多只能10人秒杀到
  • 既然库存量和参加秒杀人数比率相差这么大,就应该把尽可能多的请求拦截在上游
  • 使用缓存,减少落在DB上的无效请求
  • 某些操作异步化,加快接口响应
  • 快速失败机制,没库存或者异常统一当做秒杀失败处理
  • 是否采取限流策略,防接口被刷
  • 接口里秒杀时间段限制
  • 防止同一用户秒杀到多次

我们这边的架构设计如下图:通过nginx负载请求在7台机器上,中间结合redis缓存、kafka处理,最终到主从复制的mysql:

我这里就只部署了一台应用服务来模拟,下面将结合代码一步一步的还原接口开发过程,相应的代码均在github上:https://github.com/simonsfan/SpikeDemo。同时下面的代码是经过简化抽取出来的。先看下表结构:


 
 
  1. CREATE TABLE `product` (
  2. `id` INT NOT NULL AUTO_INCREMENT,
  3. `product_name` VARCHAR( 50) NOT NULL COMMENT '商品名称',
  4. `product_stock` INT NOT NULL COMMENT '商品库存数量',
  5. `product_sold` INT( 11) NOT NULL COMMENT '已售数量',
  6. `create_time` datetime DEFAULT '1970-01-01 00:00:00' COMMENT '参与时间',
  7. `update_time` datetime DEFAULT '1970-01-01 00:00:00' COMMENT '更新时间',
  8. `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁,版本号',
  9. PRIMARY KEY ( `id`),
  10. INDEX idx_product_name(product_name)
  11. ) ENGINE= INNODB AUTO_INCREMENT= 1 DEFAULT CHARSET=UTF8 COMMENT '商品库存信息表';
  12. CREATE TABLE `pro_order` (
  13. `id` INT NOT NULL AUTO_INCREMENT,
  14. `product_id` INT NOT NULL COMMENT '商品库存ID',
  15. `order_id` BIGINT NOT NULL COMMENT '订单ID',
  16. `user_name` varchar( 50) NOT NULL COMMENT '用户名',
  17. `create_time` datetime DEFAULT '1970-01-01 00:00:00' COMMENT '参与时间',
  18. `update_time` datetime DEFAULT '1970-01-01 00:00:00' COMMENT '更新时间',
  19. PRIMARY KEY ( `id`)
  20. ) ENGINE= INNODB AUTO_INCREMENT= 1 DEFAULT CHARSET=UTF8 COMMENT '订单信息表';

pushpin 无同步限制 


 
 
  1. @Slf4j
  2. @Service
  3. @Transactional
  4. public class SpikeServiceImpl implements SpikeService {
  5. @Autowired
  6. private OrderService orderService;
  7. @Autowired
  8. private ProductService productService;
  9. @Override
  10. public String spike(Product product,String userName) {
  11. Integer productId = Integer.valueOf(product.getId());
  12. //查库存
  13. Product restProduct = productService.checkStock(productId);
  14. if (product.getProductStock() <= 0) {
  15. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  16. }
  17. //更新库存
  18. int updateStockCount = productService.updateStock(productId);
  19. //生成订单记录
  20. Long orderId = Long.parseLong(RandomStringUtils.randomNumeric( 18));
  21. orderService.createOrder( new ProOrder(productId, orderId, userName));
  22. return ResultUtil.success(ResultEnum.SUCCESS.getCode(),ResultEnum.SUCCESS.getMessage());
  23. }
  24. }

 
 
  1. @Slf4j
  2. @Controller
  3. public class SpikeController {
  4. @Autowired
  5. private SpikeService spikeService;
  6. @AccessLimit(time = 1, threshold = 5)
  7. @ResponseBody
  8. @RequestMapping(method = RequestMethod.GET, value = "/spike")
  9. public String spike(@RequestParam(value = "id", required = false) String id,
  10. @RequestParam(value = "username", required = false) String userName) {
  11. log.info( "/spike params:id={},username={}", id, userName);
  12. Product product = new Product();
  13. try {
  14. if (StringUtils.isEmpty(id)) {
  15. return ResultUtil.fail();
  16. }
  17. Integer productId = Integer.valueOf(id);
  18. product.setId(productId);
  19. } catch (Exception e) {
  20. log.error( "spike fail username={},e={}", userName, e.toString());
  21. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  22. }
  23. return spikeService.spike(product, userName);
  24. }
  25. }

 
 
  1. < update id= "updateStock" parameterType= "java.lang.Integer">
  2. update product set product_stock=product_stock -1,product_sold=product_sold+ 1,update_time= now() where id = #{ id}
  3. </ update>

秒杀基本步骤可浓缩为:查库存-->更新库存-->生成订单,测试时候初始化了10件库存商品

访问http://localhost:8080/spike?id=1&username=simons,发现秒杀成功,库存变为9,同时也生成了该用户的订单记录

看起来没啥问题,但是bug很明显,查库存和更新库存两个操作不具有原子性,就有并发问题,于是用jmeter压测下(jmeter使用教程传送门),模拟了40个用户并发访问秒杀接口,"秒杀模拟参数.txt"文件部分内容如下:


 
 
  1. username, id
  2. simons_1, 1
  3. simons_2, 1
  4. simons_3, 1
  5. ……

看看库存表和订单表数据:

结果不出预料的卖超啦!!!


pushpin 加上乐观锁控制扣减库存 

乐观锁的简单体现就是给表加上个version字段,用于每次update时判断依据,如果update时和之前select取出来的version相等就允许更新,否则说明有并发操作。在我这里的体现就是更新库存时候判断version字段和判断库存取出来时候的version是否相同,不相同就说明有人并发修改过了,舍弃这个update操作(或者在update库存时的where条件中加上where product_stock>0都行)。


 
 
  1. @Slf4j
  2. @Service
  3. @Transactional
  4. public class SpikeServiceImpl implements SpikeService {
  5. @Autowired
  6. private OrderService orderService;
  7. @Autowired
  8. private ProductService productService;
  9. @Override
  10. public String spike(Product pro, String userName) {
  11. Integer productId = Integer.valueOf(pro.getId());
  12. //查库存
  13. Product product = productService.checkStock(productId);
  14. if (product.getProductStock() <= 0) {
  15. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  16. }
  17. //更新库存,和第一版的区别就在这个地方
  18. int updateStockCount = productService.updateStockVersion(product);
  19. if (updateStockCount == 0) {
  20. log.error( "username={},id={}秒杀失败", userName, pro.getId());
  21. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  22. }
  23. //生成订单记录
  24. Long orderId = Long.parseLong(RandomStringUtils.randomNumeric( 18));
  25. orderService.createOrder( new ProOrder(productId, orderId, userName));
  26. return ResultUtil.success(ResultEnum.SUCCESS.getCode(),ResultEnum.SUCCESS.getMessage());
  27. }
  28. }

 
 
  1. < update id= "updateStockVersion" parameterType= "com.spike.demo.bean.Product">
  2. update product set product_stock=product_stock -1,product_sold=product_sold+ 1,update_time= now(), version= version+ 1 where id=#{ id} and version={ version}
  3. </ update>

压测时候我把 "秒杀模拟参数.txt" 文件中的用户增加至400个,也就是说模拟400个用户去秒杀商品,压测结果为:

无论怎么压测,发现在10个库存的情况下一定只有10个人能秒杀到商品,控制住了库存量。但是这里我们重点关注一下,经过多次的压测,接口的TPS(每秒事务处理数,简单理解为吞吐量)平均在200左右:


pushpin 使用redis缓存减少DB压力 

上面分析到了既然秒杀的商品库存和参与秒杀的人数比率很小,真正秒杀成功的人其实很少,那么我们就应该尽量把请求拦截在上游,比如400个请求,前10个人就秒杀成功了,剩下的390个请求就可以快速返回了。于是当库存量为0时候,我们在redis中表标识已经无剩余库存,或者索性使用redis的decr or incr方法来控制库存和version,都行,redis基于内存操作非常快,我这里是使用库存标识。


 
 
  1. @Slf4j
  2. @Service
  3. @Transactional
  4. public class SpikeServiceImpl implements SpikeService {
  5. @Autowired
  6. private OrderService orderService;
  7. @Autowired
  8. private ProductService productService;
  9. @Resource(name = "redisTemplate")
  10. private RedisTemplate<String, Object> redisTemplate;
  11. private final String SPIKE_KEY = "spike:limit";
  12. private final String SPIKE_VALUE = "over";
  13. @Override
  14. public String spike(Product pro, String userName) {
  15. Integer productId = Integer.valueOf(pro.getId());
  16. //查库存前先判断是否缓存里有库存为0的标识
  17. String flag = (String) redisTemplate.opsForValue().get(SPIKE_KEY);
  18. if (StringUtils.isNotEmpty(flag)) {
  19. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  20. }
  21. Product product = productService.checkStock(productId);
  22. if (product.getProductStock() <= 0) {
  23. redisTemplate.opsForValue().set(SPIKE_KEY, SPIKE_VALUE, 12, TimeUnit.HOURS);
  24. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  25. }
  26. //更新库存
  27. int updateStockCount = productService.updateStockVersion(product);
  28. if (updateStockCount == 0) {
  29. log.error( "username={},id={}秒杀失败", userName, pro.getId());
  30. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  31. }
  32. //生成订单记录
  33. Long orderId = Long.parseLong(RandomStringUtils.randomNumeric( 18));
  34. orderService.createOrder( new ProOrder(productId, orderId, userName));
  35. return ResultUtil.success(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage());
  36. }
  37. }

和上面一样也是模拟的400个用户,多次压测后使用缓存后接口的TPS平均在350左右,每秒提升了150个TPS,吞吐量显著增大。


pushpin 使用zookeeper分布式锁来控制库存量 

zookeeper作为一个高性能、稳定的分布式协调方案、服务,广泛用于Hadoop、Storm、Kafka等产品,zookeeper中的临时有序节点特性非常适合用来实现分布式锁(关于zk实现分布式锁传送门)。


 
 
  1. @Slf4j
  2. @Service
  3. @Transactional
  4. public class SpikeServiceImpl implements SpikeService {
  5. @Autowired
  6. private OrderService orderService;
  7. @Autowired
  8. private ProductService productService;
  9. @Override
  10. public String spike(Product pro, String userName) {
  11. DistributedLock lock = new DistributedLock( "spike-act");
  12. try {
  13. Integer productId = Integer.valueOf(pro.getId());
  14. if (lock.acquireLock()) {
  15. //查库存
  16. Product product = productService.checkStock(productId);
  17. if (product.getProductStock() <= 0) {
  18. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  19. }
  20. //更新库存
  21. productService.updateStock(product.getId());
  22. //生成订单记录
  23. Long orderId = Long.parseLong(RandomStringUtils.randomNumeric( 18));
  24. orderService.createOrder( new ProOrder(productId, orderId, userName));
  25. return ResultUtil.success(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage());
  26. } else {
  27. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  28. }
  29. } finally {
  30. lock.releaseLock();
  31. }
  32. }
  33. }

多次压测后,zookeeper实现的分布式锁也能较好的保证库存不卖超,但是接口的TPS较低,平均在10左右(同上400个请求),比乐观锁控制库存和乐观锁加缓存的TPS都相差甚大,如图:

尤其像我们项目的接口服务由nginx负载在7台机器上,这个时候使用zookeeper的话,一旦使用zk分布式锁,这就相当于把7台机器当做一台来用了,都在排队等候,吞吐量实在太低!!!


pushpin kafka异步削峰 

从秒杀这个过程来看,核心逻辑除了扣减库存以外,剩下的就是生成订单信息了,也就是说mysql并发写也是个瓶颈,因此我们把生成订单操作丢到kafka队列里去执行,让它去串行化执行这个操作,起到高并发场景下的削峰作用。


 
 
  1. @Slf4j
  2. @Service
  3. @Transactional
  4. public class SpikeServiceImpl implements SpikeService {
  5. @Autowired
  6. private OrderService orderService;
  7. @Autowired
  8. private ProductService productService;
  9. @Resource(name = "redisTemplate")
  10. private RedisTemplate<String, Object> redisTemplate;
  11. @Autowired
  12. private KafkaTemplate<String, String> kafkaTemplate;
  13. private final String SPIKE_KEY = "spike:limit";
  14. private final String SPIKE_VALUE = "over";
  15. private final String SPIKE_TOPIC = "spike_topic";
  16. @Override
  17. public String spike(Product pro, String userName) {
  18. Integer productId = Integer.valueOf(pro.getId());
  19. //查库存前先判断是否缓存里有库存为0的标识
  20. String flag = (String) redisTemplate.opsForValue().get(SPIKE_KEY);
  21. if (StringUtils.isNotEmpty(flag)) {
  22. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  23. }
  24. Product product = productService.checkStock(productId);
  25. if (product.getProductStock() <= 0) {
  26. redisTemplate.opsForValue().set(SPIKE_KEY, SPIKE_VALUE, 12, TimeUnit.HOURS);
  27. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  28. }
  29. //更新库存
  30. int updateStockCount = productService.updateStockVersion(product);
  31. if (updateStockCount == 0) {
  32. log.error( "username={},id={}秒杀失败", userName, pro.getId());
  33. return ResultUtil.success(ResultEnum.SPIKEFAIL.getCode(), ResultEnum.SPIKEFAIL.getMessage());
  34. }
  35. //推送给kafka处理 生成订单操作
  36. String content = productId + ":" + userName;
  37. kafkaTemplate.send(SPIKE_TOPIC, content);
  38. return ResultUtil.success(ResultEnum.SUCCESS);
  39. }
  40. @KafkaListener(topics = SPIKE_TOPIC)
  41. public void messageConsumerHandler(String content) {
  42. log.info( "进入kafka消费队列==========content:{}", content);
  43. //生成订单记录
  44. Long orderId = Long.parseLong(RandomStringUtils.randomNumeric( 18));
  45. String[] split = content.split( ":");
  46. orderService.createOrder( new ProOrder(Integer.valueOf(split[ 0]), orderId, split[ 1]));
  47. log.info( "生成订单success");
  48. }
  49. }

压测结果就不贴图了。 


pushpin 接口限流 

对接口进行限流能较好的保护应用服务,限制用户每秒访问接口次数,防止用户恶意刷接口,具体的代码如下:


 
 
  1. /**
  2. * @ClassName AccessLimit
  3. * @Description 自定义注解
  4. * @Author simonsfan
  5. * @Date 2018/12/19
  6. * Version 1.0
  7. */
  8. @Documented
  9. @Inherited
  10. @Retention(value = RetentionPolicy.RUNTIME)
  11. @Target(value = ElementType.METHOD)
  12. public @interface AccessLimit {
  13. /**
  14. * 默认1秒内限制4次
  15. * @return
  16. */
  17. int threshold() default 1;
  18. //单位: 秒
  19. int time() default 4;
  20. }

 
 
  1. /**
  2. * @ClassName AccessLimitInterceptor
  3. * @Description 自定义拦截器
  4. * @Author simonsfan
  5. * @Date 2018/12/19
  6. * Version 1.0
  7. */
  8. @Component
  9. public class AccessLimitInterceptor extends HandlerInterceptorAdapter {
  10. @Resource(name= "redisTemplate")
  11. private RedisTemplate<String, Integer> redisTemplate;
  12. @Override
  13. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  14. if (handler instanceof HandlerMethod) {
  15. HandlerMethod handlerMethod = (HandlerMethod) handler;
  16. Method method = handlerMethod.getMethod();
  17. boolean annotationPresent = method.isAnnotationPresent(AccessLimit.class);
  18. if (!annotationPresent) {
  19. return false;
  20. }
  21. AccessLimit accessLimit = method.getAnnotation(AccessLimit.class);
  22. int threshold = accessLimit.threshold();
  23. int time = accessLimit.time();
  24. String ip = IpUtil.getIpAddrAdvanced(request);
  25. Integer limitRecord = redisTemplate.opsForValue().get(ip);
  26. if (limitRecord == null) {
  27. redisTemplate.opsForValue().set(ip, 1, time, TimeUnit.SECONDS);
  28. } else if (limitRecord < threshold) {
  29. redisTemplate.opsForValue().set(ip, limitRecord+ 1, time, TimeUnit.SECONDS);
  30. } else {
  31. outPut(response, ResultEnum.FREQUENT);
  32. return false;
  33. }
  34. }
  35. return true;
  36. }
  37. public void outPut(HttpServletResponse response, ResultEnum resultEnum) {
  38. try {
  39. response.getWriter().write(ResultUtil.success(resultEnum.getCode(),resultEnum.getMessage()));
  40. } catch (IOException e) {
  41. e.printStackTrace();
  42. }
  43. }
  44. @Override
  45. public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
  46. }
  47. @Override
  48. public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
  49. }
  50. }

 
 
  1. /**
  2. * @ClassName InterceptorConfig
  3. * @Description 把自定义的拦截器加入配置
  4. * @Author simonsfan
  5. * @Date 2018/12/19
  6. * Version 1.0
  7. */
  8. @Configuration
  9. public class InterceptorConfig implements WebMvcConfigurer {
  10. @Autowired
  11. private AccessLimitInterceptor accessLimitInterceptor;
  12. @Override
  13. public void addInterceptors(InterceptorRegistry registry) {
  14. registry.addInterceptor(accessLimitInterceptor);
  15. }
  16. }

使用时将加在注解@AccessLimit加在controller的spike方法上即可,比如


 
 
  1. @AccessLimit(threshold = 2, time = 5)
  2. @ResponseBody
  3. @RequestMapping(method = RequestMethod.GET, value = "/spike")
  4. public String spike() {
  5. }

限流的文章具体的可以看这篇:https://blog.csdn.net/fanrenxiang/article/details/80683378


还有两个: 接口里要控制好秒杀时间段,也就是什么时候可以秒杀,不能秒杀倒计时还没开始就被刷接口了,这两个开始、结束时间段维护在mysql表中,可以灵活修改,也方便测试。同时为了不允许同一个用户秒杀成功多次,中奖记录表(订单表)里设置了username和奖品id(商品id)的联合唯一索引,重复秒杀即手动捕获异常处理即可。


books 项目github:https://github.com/simonsfan/SpikeDemo

books Jmeter自定义变量压测接口:https://blog.csdn.net/fanrenxiang/article/details/80753159

books Zookeeper实现分布式锁:https://blog.csdn.net/fanrenxiang/article/details/81704691

books 接口限流:https://blog.csdn.net/fanrenxiang/article/details/80683378

books Kafka quick start:http://kafka.apache.org/quickstart

books 本文用到的压测jmeter文件及秒杀模拟参数.tx文件:https://pan.baidu.com/s/1JMxDCp-wQVFJTpSXUhhLbA

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值