从零搭建秒杀系统
Posted 再见丶孙悟空
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零搭建秒杀系统相关的知识,希望对你有一定的参考价值。
前言
本文将从零开始搭建一个秒杀的后台系统,整体思路如下图所示
前置准备
- 整体后端框架采用的是 SpringBoot + mybatis plus
- 运用到 redis ,rabbitmq 等中间件
- 性能测试用到了 jmeter
正文
秒杀在生活中的应用场景还是挺多的,比如双十一抢限购商品,12306抢座,大学抢课,抢门票等等。
这些场景下,就有可能带出以下问题
- 高并发
- 极短的时间内,用户请求量大
- 超卖
- 库存 100 件,最终下单了 120 件
- 恶意请求
- 一些不坏好意的黑客,或者黄牛,通过脚本来模拟请求。如果是用来抢商品的,机器的请求肯定比人快,那顶多算欺负老实人;要是恶意伪造请求,造成缓存穿透,处理不好整个服务都挂了。
- 数据库
- 上万甚至上百万的 qps 打到数据库,如果没有做降级,限流,熔断等处理,可能影响的就不是秒杀这一个业务了。
所以在我们设计的时候,就需要根据这些问题,对症下药。
1 普通下单
建立一个简单的场景,数据库中存有一个商品,库存为 100,用户通过下单接口来下单,不做任何限制。
public int createWrongOrder(int sid) throws Exception
//校验库存
Stock stock = checkStock(sid);
//扣库存
saleStock(stock);
//创建订单
int id = createOrder(stock);
return id;
通过 jmeter 进行性能测试,设置线程数1000,模拟 1000 位用户进行请求,观察结果。
可以看到 http 请求全部正常返回,销量只卖出了 27 单,但是订单表里添加了 1000 条记录
这就是之前提出的超卖问题。
2 下单加锁(乐观锁)
解决上述问题,我采用上锁的方式,选择的是乐观锁
乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。
public int createOptimisticOrder(int sid)
//校验库存
Stock stock = checkStock(sid);
//乐观锁更新库存
boolean success = saleStockOptimistic(stock);
if (!success)
throw new RuntimeException("过期库存值,更新失败");
//创建订单
int id = createOrder(stock);
return stock.getCount() - stock.getSale();
代码层面,就修改了一下更新库存的 sql
public int updateStockByOptimistic(Stock stock)
UpdateWrapper<Stock> wrapper = new UpdateWrapper<>();
wrapper.lambda().eq(Stock::getId, stock.getId()).eq(Stock::getVersion, stock.getVersion());
stock.setSale(stock.getSale() + 1);
stock.setVersion(stock.getVersion() + 1);
return mapper.update(stock, wrapper);
翻译成 sql 语句就是
UPDATE stock
SET sale = sale + 1,
version = version + 1
WHERE
id = 1
AND version = 0
继续用 jmeter 进行测试。日志中可以看到,存在大量购买失败,销售量为 47,但是订单量也为 47,说明不存在超卖的情况。
3 下单接口限流
解决了超卖的问题,接下来需要解决高并发下带来的压力。
因此,我们需要选择更优雅的方式来处理大量请求。
首先是前端。
- 页面静态化
- 可以对页面进行静态化处理。因为前端作为秒杀活动的入口,如果把入口限制住,就能很好的达到限流的效果。到了秒杀时间点,并且用户主动点了秒杀按钮,才会访问服务端。
- CDN 缓存
- 到了秒杀时间点,再更新秒杀按钮。
而作为一名后端开发,本文的重点更多的在于后端的限流。
- 单独部署
- 一种最常见的方式,就是单独部署,以免秒杀业务崩溃而影响其他业务系统。
- 缓存
- 添加缓存可以避免请求直接打到数据库。具体过程如下:根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果商品不存在,则直接提示失败。
- 这也会引发其他问题
- 缓存击穿
- 比如某一时刻缓存失效,大量请求还是会直接打到数据库。此时可以根据实际情况,将缓存的有效期设置为不失效,并在秒杀活动开始前,对缓存进行预热。同时对数据库查询加锁
- 缓存穿透
- 商品id可能非法,也会导致直接访问数据库的情况。加锁可以较好的缓解这一情况,同时,我们可以使用布隆过滤器,也能很好的解决这个问题。
- 缓存击穿
- 接口限流
- 这边以令牌桶限流算法为例
- 代码层面,使用Guava的RateLimiter实现令牌桶限流接口
-
// 每秒放行10个请求 private RateLimiter rateLimiter = RateLimiter.create(10); @GetMapping("/createOptimisticOrder/sid") public String createOptimisticOrder(@PathVariable int sid) // 1. 阻塞式获取令牌 log.info("等待时间" + rateLimiter.acquire()); // 2. 非阻塞式获取令牌 // if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) // log.warn("你被限流了,真不幸,直接返回失败"); // return "你被限流了,真不幸,直接返回失败"; // int id; try id = stockOrderService.createOptimisticOrder(sid); log.info("购买成功,剩余库存为: []", id); catch (Exception e) log.error("购买失败:[]", e.getMessage()); return "购买失败,库存不足"; return String.format("购买成功,剩余库存为:%d", id);
- 两种方式获取令牌
- 非阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,会尝试等待设置好的时间(这里写了1000ms),其会自动判断在1000ms后,这个请求能不能拿到令牌,如果不能拿到,直接返回抢购失败。如果timeout设置为0,则等于阻塞时获取令牌。
- 阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,就在这里阻塞住,等待令牌的发放。
jmeter 测试,采用阻塞式获取令牌,可以看到吞吐量为 10
再看订单情况,售出了 100 个,订单也有 100 条
4 下单接口加盐
我们的接口可以通过抓包轻易获取到,这会给一些不法分子可乘之机。
一个简单的做法就是给我们的接口地址加盐,即动态的生成下单地址。
获取盐值接口
@GetMapping(value = "/getVerifyHash")
public String getVerifyHash(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId)
String hash;
try
hash = userService.getVerifyHash(sid, userId);
catch (Exception e)
log.error("获取验证hash失败,原因:[]", e.getMessage());
return "获取验证hash失败";
return String.format("请求抢购验证hash值为:%s", hash);
加盐下单接口
@GetMapping(value = "/createOrderWithVerifiedUrl")
public String createOrderWithVerifiedUrl(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId,
@RequestParam(value = "verifyHash") String verifyHash)
int stockLeft;
try
stockLeft = stockOrderService.createVerifiedOrder(sid, userId, verifyHash);
log.info("购买成功,剩余库存为: []", stockLeft);
catch (Exception e)
log.error("购买失败:[]", e.getMessage());
return e.getMessage();
return String.format("购买成功,剩余库存为:%d", stockLeft);
另外,可以限制用户下单的频率。
@GetMapping(value = "/createOrderWithVerifiedUrl")
public String createOrderWithVerifiedUrl(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId,
@RequestParam(value = "verifyHash") String verifyHash)
int stockLeft;
try
int count = userService.addUserCount(userId);
log.info("用户截至该次的访问次数为: []", count);
boolean isBanned = userService.getUserIsBanned(userId);
if (isBanned)
return "购买失败,超过频率限制";
stockLeft = stockOrderService.createVerifiedOrder(sid, userId, verifyHash);
log.info("购买成功,剩余库存为: []", stockLeft);
catch (Exception e)
log.error("购买失败:[]", e.getMessage());
return e.getMessage();
return String.format("购买成功,剩余库存为:%d", stockLeft);
用户访问频率可以放在缓存 redis 或者 memcached 中,限制了单个用户最多抢5单,(注意是抢5单,而不是限购5单),最终发现抢到了2单(这边抢到的单数属于随机事件)
5 保证 Redis 和 数据库 数据的一致性
对于访问量很大的“热点”数据,尤其是一些读取量远大于写入量的数据,更应该被缓存,而不应该让请求打到数据库上。
缓存的优点
- 能够缩短服务的响应时间,给用户带来更好的体验。
- 能够增大系统的吞吐量,依然能够提升用户体验。
- 减轻数据库的压力,防止高峰期数据库被压垮,导致整个线上服务挂掉。
缓存的问题
- 缓存有多种选型,你是否都熟悉,如果不熟悉,无疑增加了维护的难度。
- 缓存系统也要考虑分布式,无疑增加了系统的复杂性。
- 如果对缓存的准确性有非常高的要求,就必须考虑「缓存和数据库的一致性问题」。
接下来重点讨论缓存和数据库一致性的问题
5.1 不使用更新缓存而是删除缓存
大部分观点认为,做缓存不应该是去更新缓存,而是应该删除缓存,然后由下个请求去去缓存,发现不存在后再读取数据库,写入缓存。
其实如果业务非常简单,只是去数据库拿一个值,写入缓存,那么更新缓存也是可以的。但是,淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,建议作为通用的处理方式。
5.2 先删除缓存,还是先操作数据库?
方案一 先删缓存,再更新数据库
该方案会导致请求数据不一致
同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
方案二 先更新数据库,再删缓存
假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生
(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存
如果发生上述情况,确实是会发生脏数据。
发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,「数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。
所以,如果你想实现基础的缓存数据库双写一致的逻辑,那么在大多数情况下,在不想做过多设计,增加太大工作量的情况下,请 先更新数据库,再删缓存!
方案三 先删缓存,再更新数据库,过一段时间,再同步/异步 删一次缓存
(1)先淘汰缓存
(2)再写数据库(这两步和原来一样)
(3)休眠1秒,再次淘汰缓存
这么做,可以将1秒内所造成的缓存脏数据,再次删除。
6 下单异步处理
实际秒杀过程可以分为 秒杀 - 下单 - 支付 三个步骤。大部分的流量压力是在秒杀这一步,之后的步骤完全可以异步完成。
这时候就可以用到 rabbitmq 的流量削峰的功能了。
代码层面也简单,新增一个 controller 接口
/**
* 下单接口:异步处理订单
*/
@GetMapping(value = "/createUserOrderWithMq")
public String createUserOrderWithMq(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId)
try
// 检查缓存中该用户是否已经下单过
Boolean hasOrder = stockOrderService.checkUserOrderInfoInCache(sid, userId);
if (hasOrder != null && hasOrder)
log.info("该用户已经抢购过");
return "你已经抢购过了,不要太贪心.....";
// 没有下单过,检查缓存中商品是否还有库存
log.info("没有抢购过,检查缓存中商品是否还有库存");
Integer count = stockService.getStockCount(sid);
if (count == 0)
return "秒杀请求失败,库存不足.....";
// 有库存,则将用户id和商品id封装为消息体传给消息队列处理
// 注意这里的有库存和已经下单都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证
log.info("有库存:[]", count);
RabbitOrderDTO dto = new RabbitOrderDTO();
dto.setSid(sid);
dto.setUserId(userId);
sendToOrderQueue(JSONObject.toJSONString(dto));
return "秒杀请求提交成功";
catch (Exception e)
log.error("下单接口:异步处理订单异常:", e);
return "秒杀请求失败,服务器正忙.....";
再配置一个消费者
@Component
@RabbitListener(queues = "orderQueue")
@Slf4j
public class OrderMqListener
@Autowired
private StockOrderService orderService;
@RabbitHandler
public void process(String message)
log.info("OrderMqReceiver收到消息开始用户下单流程: " + message);
try
RabbitOrderDTO dto = JSONObject.parseObject(message, RabbitOrderDTO.class);
orderService.createOrderByMq(dto.getSid(), dto.getUserId());
catch (Exception e)
log.error("消息处理异常:", e);
异步与非异步性能对比
非异步下单(添加乐观锁,不限流,不限购)下单成功 54 单,吞吐量大约为 145
异步下单 100单全部卖出,吞吐量达 448
由此可以明显感受到异步下单的优越性的
总结
至此,我们从超卖,高并发,缓存,限流,超链接加盐等多个角度简单设计了一个秒杀系统。但是实际生产运用过程中,要考虑的东西远不止这些,像缓存击穿,缓存穿透,分布式锁的设计,mq队列消息丢失,或者重复消费的问题,都是需要根据实际情况具体问题具体分析的。实践出真知,希望有朝一日能真正运用到生产中吧。
源码地址
https://github.com/kid626/seckill
参考
以上是关于从零搭建秒杀系统的主要内容,如果未能解决你的问题,请参考以下文章