引言
本文是github.com/qqxx6661/mi...的学习笔记,欢迎大家学习!
一:防止超卖
使用version乐观锁 → 无法卖出全部商品
xml
<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,这样反而降低了性能,对于这种情况使用悲观锁就更合适。
实现不需要版本号字段的乐观锁
xml
<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>
悲观锁
悲观锁在大量请求的请求下,有着更好的卖出成功率。但是需要注意的是,如果请求量巨大,悲观锁会导致后面的请求进行了长时间的阻塞等待,用户就必须在页面等待,很像是"假死",可以通过配合令牌桶限流,或者是给用户显著的等待提示来优化。
xml
<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 最好结合时间戳随机数字/字符
java
@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
java
@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进行等待,这样会阻塞用户的请求。
删缓存失败了怎么办?重试机制
方案一:对业务代码造成大量的侵入
方案二:使用canel
五:优雅的实现订单异步处理
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)同时,前端不断请求检查用户和商品是否已经有订单的接口,如果得到订单已经处理完成的消息看,页面跳转抢购成功。