系统特点
- 高性能:秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键
- 一致性:秒杀商品减库存的实现方式同样关键,有限数量的商品在同一时刻被很多倍的请求同时来减库存,在大并发更新的过程中都要保证数据的准确性。
- 高可用秒杀时会在一瞬间涌入大量的流量,为了避免系统宕机,保证高可用,需要做好流量限制
- 防超卖 防止销售量大于库存量,造成巨大损失
优化思路
-
后端优化:将请求尽量拦截在系统上游
- 限流屏蔽掉无用的流量,允许少部分流量走后端。假设现在库存为 10,有 1000 个购买请求,最终只有 10 个可以成功,99% 的请求都是无效请求
- 削峰秒杀请求在时间上高度集中于某一个时间点,瞬时流量容易压垮系统,因此需要对流量进行削峰处理,缓冲瞬时流量,尽量让服务器对资源进行平缓处理
- 异步将同步请求转换为异步请求,来提高并发量,本质也是削峰处理
- 利用缓存创建订单时,每次都需要先查询判断库存,只有少部分成功的请求才会创建订单,因此可以将商品信息放在缓存中,减少数据库查询
- 负载均衡利用 Nginx 等使用多个服务器并发处理请求,减少单个服务器压力
- 熔断 使用Hystrix防止分布式服务级联崩溃,调用出错时进行熔断处理,返回静态失败报文
-
前端优化
- 限流前端答题或验证码,来分散用户的请求
- 禁止重复提交限定每个用户发起一次秒杀后,需等待才可以发起另一次请求,从而减少用户的重复请求
- 本地标记用户成功秒杀到商品后,将提交按钮置灰,禁止用户再次提交请求
- 动静分离将前端静态数据直接缓存到离用户最近的地方,比如用户浏览器、CDN 或者服务端的缓存中
-
防作弊优化
- 隐藏秒杀接口如果秒杀地址直接暴露,在秒杀开始前可能会被恶意用户来刷接口,因此需要在没到秒杀开始时间不能获取秒杀接口,只有秒杀开始了,才返回秒杀地址 URL和验证 MD5,用户拿到这两个数据才可以进行秒杀
- 同一个账号多次发出请求在前端优化的禁止重复提交可以进行优化;也可以使用 Redis 标志位,每个用户的所有请求都尝试在 Redis 中插入一个
userId_secondsKill
标志位,成功插入的才可以执行后续的秒杀逻辑,其他被过滤掉,执行完秒杀逻辑后,删除标志位 - 多个账号一次性发出多个请求一般这种请求都来自同一个 IP 地址,可以检测 IP 的请求频率,如果过于频繁则弹出一个验证码
- 多个账号不同 IP 发起不同请求这种一般都是僵尸账号,检测账号的活跃度或者等级等信息,来进行限制。比如微博抽奖,用 IPhone 的年轻女性用户中奖几率更大。通过用户画像限制僵尸号无法参与秒杀或秒杀不能成功
-
防超卖
我们日常的下单过程中防止超卖一般是通过在数据库上实施乐观锁来完成,使用乐观锁虽然比for update这种悲观锁方式性能要好很多,但是还是无法满足秒杀的上万并发需求,我们的方案其实也很简单实时库存的扣减在缓存中进行,异步扣减数据库中的库存,保证缓存中和数据库中库存的最终一致性 ^c399f5
代码优化
基本的秒杀逻辑
@Override
public int createWrongOrder(int sid) throws Exception {
// 数据库校验库存
Stock stock = checkStock(sid);
// 扣库存(无锁)
saleStock(stock);
// 生成订单
int res = createOrder(stock);
return res;
}
private Stock checkStock(int sid) throws Exception {
Stock stock = stockService.getStockById(sid);
if (stock.getCount() < 1) {
throw new RuntimeException("库存不足");
}
return stock;
}
private int saleStock(Stock stock) {
stock.setSale(stock.getSale() + 1);
stock.setCount(stock.getCount() - 1);
return stockService.updateStockById(stock);
}
private int createOrder(Stock stock) throws Exception {
StockOrder order = new StockOrder();
order.setSid(stock.getId());
order.setName(stock.getName());
order.setCreateTime(new Date());
int res = orderMapper.insertSelective(order);
if (res == 0) {
throw new RuntimeException("创建订单失败");
}
return res;
}
// 扣库存 Mapper 文件
@Update("UPDATE stock SET count = #{count, jdbcType = INTEGER}, name = #{name, jdbcType = VARCHAR}, " + "sale = #{sale,jdbcType = INTEGER},version = #{version,jdbcType = INTEGER} " + "WHERE id = #{id, jdbcType = INTEGER}")