秒杀系统总结

引言

本文是https://github.com/qqxx6661/miaosha的学习笔记,欢迎大家学习!

一:防止超卖

使用version乐观锁 → 无法卖出全部商品

<update id="updateByOptimistic" parameterType="cn.monitor4all.miaoshadao.dao.Stock">
  update stock
  <set>
    sale = sale + 1,
    version = version + 1,
  set>
  WHERE id = #{id,jdbcType=INTEGER}
  AND version = #{version,jdbcType=INTEGER}
update>

二:令牌桶限流+再谈超卖

接口限流本身也是系统安全防护的一种措施

令牌桶算法与漏桶算法

漏桶算法能够强行限制数据的传输速率,而令牌桶算法在能够限制数据的平均传输速率外,还允许某种程度的突发传输

(1)阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,就在这里阻塞住,等待令牌的发放。

(2)非阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,会尝试等待设置好的时间(这里写了1000ms),其会自动判断在1000ms后,这个请求能不能拿到令牌,如果不能拿到,直接返回抢购失败。如果timeout设置为0,则等于阻塞时获取令牌。

乐观锁和悲观锁

  • 乐观锁比较适合数据修改比较少,读取比较频繁的场景,即使出现了少量的冲突,这样也省去了大量的锁的开销,故而提高了系统的吞吐量。
  • 但是如果经常发生冲突(写数据比较多的情况下),上层应用不不断的retry,这样反而降低了性能,对于这种情况使用悲观锁就更合适。

实现不需要版本号字段的乐观锁

<update id="updateByOptimistic" parameterType="cn.monitor4all.miaoshadao.dao.Stock">
    update stock
    <set>
      sale = sale + 1,
    set>
    WHERE id = #{id,jdbcType=INTEGER}
    AND sale = #{sale,jdbcType=INTEGER}
update>

悲观锁

悲观锁在大量请求的请求下,有着更好的卖出成功率。但是需要注意的是,如果请求量巨大,悲观锁会导致后面的请求进行了长时间的阻塞等待,用户就必须在页面等待,很像是“假死”,可以通过配合令牌桶限流,或者是给用户显著的等待提示来优化。

<select id="selectByPrimaryKeyForUpdate" resultMap="BaseResultMap" parameterType="java.lang.Integer" >
  select
  <include refid="Base_Column_List" />
  from stock
  where id = #{id,jdbcType=INTEGER}
  FOR UPDATE
select>

三:抢购接口隐藏+单用户限制频率

抢购接口隐藏

需要将抢购接口进行隐藏,抢购接口隐藏(接口加盐)的具体做法

  • 每次点击秒杀按钮,先从服务器获取一个秒杀验证值(接口内判断是否到秒杀时间)。
  • Redis以缓存用户ID和商品ID为Key,秒杀地址为Value缓存验证值
  • 用户请求秒杀商品的时候,要带上秒杀验证值进行校验。
  • SALT 最好结合时间戳随机数字/字符
@Override
public String getVerifyHash(Integer sid, Integer userId) throws Exception {

    // 验证是否在抢购时间内
    LOGGER.info("请自行验证是否在抢购时间内");

    // 检查用户合法性
    User user = userMapper.selectByPrimaryKey(userId.longValue());
    if (user == null) {
        throw new Exception("用户不存在");
    }
    LOGGER.info("用户信息:[{}]", user.toString());

    // 检查商品合法性
    Stock stock = stockService.getStockById(sid);
    if (stock == null) {
        throw new Exception("商品不存在");
    }
    LOGGER.info("商品信息:[{}]", stock.toString());

    // 生成hash
	  // SALT 最好结合时间戳随时数字/字符
    String verify = SALT + sid + userId;
    String verifyHash = DigestUtils.md5DigestAsHex(verify.getBytes());

    // 将hash和用户商品信息存入redis
    String hashKey = CacheKey.HASH_KEY.getKey() + "_" + sid + "_" + userId;
    stringRedisTemplate.opsForValue().set(hashKey, verifyHash, 3600, TimeUnit.SECONDS);
    LOGGER.info("Redis写入:[{}] [{}]", hashKey, verifyHash);
    return verifyHash;
}

下单接口携带verifyHash

@Override
public int createVerifiedOrder(Integer sid, Integer userId, String verifyHash) throws Exception {

    // 验证是否在抢购时间内
    LOGGER.info("请自行验证是否在抢购时间内,假设此处验证成功");

    // 验证hash值合法性
    String hashKey = CacheKey.HASH_KEY.getKey() + "_" + sid + "_" + userId;
    String verifyHashInRedis = stringRedisTemplate.opsForValue().get(hashKey);
    if (!verifyHash.equals(verifyHashInRedis)) {
        throw new Exception("hash值与Redis中不符合");
    }
    LOGGER.info("验证hash值合法性成功");

    // 检查用户合法性
    User user = userMapper.selectByPrimaryKey(userId.longValue());
    if (user == null) {
        throw new Exception("用户不存在");
    }
    LOGGER.info("用户信息验证成功:[{}]", user.toString());

    // 检查商品合法性
    Stock stock = stockService.getStockById(sid);
    if (stock == null) {
        throw new Exception("商品不存在");
    }
    LOGGER.info("商品信息验证成功:[{}]", stock.toString());

    //乐观锁更新库存
    saleStockOptimistic(stock);
    LOGGER.info("乐观锁更新库存成功");

    //创建订单
    createOrderWithUserInfo(stock, userId);
    LOGGER.info("创建订单成功");

    return stock.getCount() - (stock.getSale()+1);
}

单用户限制频率

使用外部缓存Redis/Memcached来解决问题

四:缓存与数据库双目问题的争议

不使用更新缓存而是删除缓存 → 先删除缓存,还是先操作数据库?

先删缓存,再更新数据库

请求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)的读数据库耗时更短。依然会有问题,问题出现的可能性会因为上述原因,变得比较低!

数据库和缓存数据一致性

没法做到强一致性,只能做到最终一致性。

本质:需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性

延时双删

问:先删除缓存,再更新数据库中避免脏数据?

答案:采用延时双删策略。

(1)先淘汰缓存

(2)再写数据库

(3)休眠一秒,再次淘汰缓存(可以将一秒内所造成的缓存脏数据,再次删除)

写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

如果使用了MySQL的读写分离架构怎么办?

一个请求A进行更新操作,另一个请求B进行查询操作

(1)请求A进行写操作,删除缓存

(2)请求A将数据写入数据库了,

(3)请求B查询缓存发现,缓存没有值

(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值

(5)请求B将旧值写入缓存

(6)数据库完成主从同步,从库变为新值

还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。

采用这种同步淘汰策略,吞吐量降低怎么办?

那就将第二次删除作为异步

先更新数据库,再删缓存中避免脏数据?

依然可以用延时双删策略

demo 实现

最好的方法是开设一个线程池,在线程中删除key,而不是使用Thread.sleep进行等待,这样会阻塞用户的请求。

删缓存失败了怎么办?重试机制

方案一:对业务代码造成大量的侵入

秒杀系统总结_第1张图片
方案二:使用canel
秒杀系统总结_第2张图片

五:优雅的实现订单异步处理

createUserOrderWithMQ:

(1)检查缓存中该用户是否已经下单过:在MQ下单后写入redis一条用户id和商品id绑定的数据

(2)没有下单过,检测缓存中商品是否还有库存

(3)缓存中如果有库存,则将用户id和商品id封装为消息体(传给消息队列处理)

(4)这里的库存和已经下单都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证,作为兜底逻辑

真正的下单流程为:

(1)校验数据库库存

(2)乐观锁更新库存

(3)写入订单至数据库

(4)写入订单和用户信息至缓存供查询:写入后,在外层接口便可以通过判断redis中是否存在用户和商品的抢购信息,来直接给用户返回“已经抢购过”的信息

不足

(1)这种结构默认一个用户只能抢购一次这个商品

(2)使用set,key为商品id,value为用户id,每次检查需要遍历set,用户过多有性能问题

更优雅的实现

上述实现,用户点击了提交订单,收到了消息:您的订单已经提交成功。然后用户啥也没看见,也没有订单号,点到了个人中心,发现也没有订单(还在队列处理中)。

(1)让前端在提交订单后,显示一个“排队”中

(2)同时,前端不断请求检查用户和商品是否已经有订单的接口,如果得到订单已经处理完成的消息看,页面跳转抢购成功。

整体流程

秒杀系统总结_第3张图片

你可能感兴趣的:(Java,java,idea)