SpringCloud 大型系列课程正在制作中,欢迎大家关注与提意见。
本文章是系列文章中的一篇
- 1、SpringCloud 项目基础工程搭建 【SpringCloud系列1】
- 2、SpringCloud 集成Nacos注册中心 【SpringCloud系列2】
- 3、SpringCloud Feign远程调用 【SpringCloud系列3】
- 4、SpringCloud Feign远程调用公共类抽取 【SpringCloud系列4】
- 5、SpringCloud 整合Gateway服务网关 【SpringCloud系列5】
- 6、SpringCloud 整合 Spring Security 认证鉴权【SpringCloud系列6】
- SpringCloud网关Gateway认证鉴权【SpringCloud系列7】
- SpringCloud Gateway 通过redis实现限流【SpringCloud系列8】
- SpringCloud Gateway 整合阿里 Sentinel网关流控、熔断、降级【SpringCloud系列9】
- SpringCloud Gateway 整合RSA对请求参数解密、响应结果加密【SpringCloud系列10】
- SpringCloud 业务管理后台 通过FeignClient来调用oauth/token接口【SpringCloud系列11】
- Spring Security OAuth2.0 多点登录与单点登录【SpringCloud系列12】
- SpringCloud 微信小程序登录 方案一【SpringCloud系列13】
- SpringCloud 微信小程序 获取手机号一键登录 【SpringCloud系列14】
- SpringCloud 微信小程序 获取用户信息一键登录 【SpringCloud系列15】
- SpringCloud 网关实现线程池异步批量保存请求日志 【SpringCloud系列16】
1 下单减库存 V1 无锁
并发情况下,库存一塌糊涂
/**
* 创建订单
*
* @param goodsId 商品ID
* @param userId 用户ID
* @return
* @throws Exception
*/
@Override
@Transactional
public OrderInfo createWrongOrder(Long goodsId, Long userId) throws Exception {
OrderInfo orderInfo = null;
// 数据库校验库存
GoodsInfo goodsInfo = checkStock(goodsId);
// 扣库存(无锁)
boolean b = saleStock(goodsInfo);
if (!b) {
throw new RuntimeException("库存不足 减库存失败");
}
log.info("扣库存完成");
// 生成订单
orderInfo = createOrder(goodsInfo, userId);
log.info("下单完成");
return orderInfo;
}
private GoodsInfo checkStock(Long sid) throws Exception {
//查询商品详情
GoodsInfo goodsInfo = goodsInfoService.getGoodsInfoById(sid);
log.info("当前库存 {}", goodsInfo.getGoodsStock());
if (goodsInfo.getGoodsStock() < 1) {
throw new RuntimeException("库存不足");
}
return goodsInfo;
}
/**
* 更新库存
*
* @param goodsInfo
* @return
*/
private boolean saleStock(GoodsInfo goodsInfo) {
goodsInfo.setGoodsSale(goodsInfo.getGoodsSale() + 1);
goodsInfo.setGoodsStock(goodsInfo.getGoodsStock() - 1);
return goodsInfoService.updateStockById(goodsInfo);
}
10个商品库存,1秒100个并发下单测试,结果是库存正常减为0,订单创建了88个,严重超卖。
如下图是JMeter压测
其中更新库存的实现如下
@Mapper
public interface GoodsInfoMapper extends BaseMapper<GoodsInfo> {
// 扣库存 Mapper 文件
@Update("UPDATE tb_goods SET goods_stock = #{goodsStock, jdbcType = INTEGER}, goods_sale = #{goodsSale,jdbcType = INTEGER} WHERE id = #{id, jdbcType = INTEGER}")
boolean updateStockById(GoodsInfo goodsInfo);
}
1.1 synchronized(同步锁) 悲观锁
@Override
@Transactional
public synchronized OrderInfo createWrongOrder(Long goodsId, Long userId) throws Exception {
OrderInfo orderInfo = null;
// 数据库校验库存
GoodsInfo goodsInfo = checkStock(goodsId);
// 扣库存(无锁)
boolean b = saleStock(goodsInfo);
if (!b) {
throw new RuntimeException("库存不足 减库存失败");
}
log.info("扣库存完成");
// 生成订单
orderInfo = createOrder(goodsInfo, userId);
log.info("下单完成");
return orderInfo;
}
10个商品库存,1秒100个并发下单测试,结果是库存正常减为0,订单创建了13个,出现超卖。
1.2 MySql 乐观锁实现
直接在减库存的时候来判断
@Mapper
public interface GoodsInfoMapper extends BaseMapper<GoodsInfo> {
// 扣库存 Mapper 文件
@Update("UPDATE tb_goods SET goods_stock =goods_stock-1,goods_sale=goods_sale+1, version = version+1 WHERE id = #{id, jdbcType = INTEGER} AND version = #{version, jdbcType = INTEGER}")
boolean updateStockById(GoodsInfo goodsInfo);
}
每执行一次更新,version就会+1,库存更新成功后,才会继续创建订单。
压测如下:
10个商品库存,1秒100个并发下单测试,结果是库存正常减为0,订单创建了10个,正常,中间并发的请求会下单失败,
启动订单服务两个负载来压测,也可以正常下单,正常减库存
2. Redis 计数限流
在上述的压力测试中,1个商品10 个库存,有 1000 个并发秒杀请求,最终只有 10 个订单会成功创建,也就是说有 990 的请求是无效的,这些无效的请求也会给数据库带来压力,因此可以在在请求落到数据库之前就将无效的请求过滤掉,将并发控制在一个可控的范围,这样落到数据库的压力就小很多。
所以对下单接口可以出现的大量请求常常是在秒杀场景中,所以可以针对这个接口进行一下限流:
对于每个没有被过滤掉的请求,都会去数据库查询库存来判断库存是否充足,对于这个查询可以放在缓存 Redis 中,Redis 的数据是存放在内存中的,速度快很多。
对于普通商品来说,在创建商品以及修改商品时,就可以将商品信息以及库存同步到Redis中。
对于秒杀商品来说,在秒杀前几分钟,通过定时任务将秒杀商品以及库存同步到Redis中。
3 乐观锁+Redis 实现下单减库存
缓存和数据一致性 ,缓存和 数据库(DB) 的一致性是一个讨论很多的问题
3.1 先更新数据库,再更新缓存策略
假设 A、B 两个线程,A 成功更新数据,在要更新缓存时,A 的时间片用完了,B 更新了数据库接着更新了缓存,这是 CPU 再分配给 A,则 A 又更新了缓存,这种情况下缓存中就是脏数据,具体逻辑如下图所示:
解决方法就是:缓存不做更新,仅做删除,先更新数据库再删除缓存。
A 更新了数据库,还没来得及删除缓存,B 又更新了数据库,接着删除了缓存,然后 A 删除了缓存,这样只有下次缓存未命中时,才会从数据库中重建缓存,避免了脏数据。
这种方式,由于每次都删除缓存,因此导致多次缓存都不能命中,能命中缓存的次数很少,因此删除这种方案并不可取,效率也并不是很高。
实际可以使用的是 先更新数据库再更新缓存,更新数据库使用乐观锁,更新Redis缓存结合Redis事务。
2 商品信息添加到 Redis 中
我这里是测试使用,所以专门写了一个接口来将商品信息以及库存信息加载到Redis中,常量Key定义如下:
public class Constant {
public static final String GOOOD_STOCK = "STOCK_COUNT:";//库存
public static final String GOOOD_SALE= "STOCK_SALE:";//销量
public static final String GOOOD_VERSION = "STOCK_VERSION:";//版本
public static final String GOODS_NAME = "GOODS_NAME:";//商品名称
public static final String GOODS_INFO ="GOODS_INFO:";//商品
}
接下来就是商品信息对应的数据库的数据的基本操作,大家可以查看文章底部的源码。
- GoodsInfo 是商品信息
- GoodsInfoMapper 是商品信息操作Mapper
@Service
@Slf4j
public class GoodsInfoServiceImple extends ServiceImpl<GoodsInfoMapper, GoodsInfo> implements GoodsInfoService {
@Resource
GoodsInfoMapper goodsInfoMapper;
@Resource
RedisService redisService;
/**
* 测试使用将商品数据加载到redis中
*
* @param sid
*/
@Override
public void testRedisLoadGoods(Long sid) {
GoodsInfo goodsInfo = this.getById(sid);
if (goodsInfo == null) {
return;
}
Long goodsId = goodsInfo.getId();
redisService.set(Constant.GOOOD_STOCK + goodsId, goodsInfo.getGoodsStock());
redisService.set(Constant.GOOOD_SALE + goodsId, goodsInfo.getGoodsSale());
redisService.set(Constant.GOOOD_VERSION + goodsId, goodsInfo.getVersion());
redisService.set(Constant.GOODS_NAME + goodsId, goodsInfo.getGoodsName());
redisService.set(Constant.GOODS_INFO + goodsId, goodsInfo);
}
}
保存在Redis中的数据如下(这里使用的RDM工具连接的Redis服务)
然后就是实现下单的逻辑
- 第一步 校验库存,从 Redis 中获取
- 第二步 乐观锁更新 MySql 库存
- 第三步 Redis事务更新Redis缓存
- 第四步 创建订单
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, OrderInfo> implements OrderService {
@Autowired
SnowFlakeCompone snowFlakeCompone;
/**
* redis 分布式事务
*/
@Resource
RedisService redisService;
/**
* 乐观锁更新库存和Redis
* @param goodsId 商品ID
* @param userId 用户ID
* @return
* @throws Exception
*/
@Override
public OrderInfo createOrderWithLimitAndRedis(Long goodsId, Long userId) throws Exception {
// 校验库存,从 Redis 中获取
GoodsInfo goodsInfo = checkStockWithRedis(goodsId);
// 乐观锁更新库存和Redis
saleStockOptimsticWithRedis(goodsInfo);
// 创建订单
OrderInfo res = createOrder(goodsInfo, userId);
return res;
}
// Redis 中校验库存
private GoodsInfo checkStockWithRedis(Long goodsId) throws Exception {
int count = Integer.parseInt(redisService.get(Constant.GOOOD_STOCK + goodsId).toString());
int sale = Integer.parseInt(redisService.get(Constant.GOOOD_SALE + goodsId).toString());
int version = Integer.parseInt(redisService.get(Constant.GOOOD_VERSION + goodsId).toString());
String goodsName = redisService.get(Constant.GOODS_NAME + goodsId).toString();
if (count < 1) {
log.info("库存不足");
throw new RuntimeException("库存不足 Redis currentCount: " + sale);
}
GoodsInfo stock = new GoodsInfo();
stock.setId(goodsId);
stock.setGoodsStock(count);
stock.setGoodsSale(sale);
stock.setVersion(version);
stock.setGoodsName(goodsName);
return stock;
}
// 更新 DB 和 Redis
private void saleStockOptimsticWithRedis(GoodsInfo goodsInfo) throws Exception {
boolean res = goodsInfoService.updateStockByOptimistic(goodsInfo);
if (!res) {
throw new RuntimeException("并发更新库存失败");
}
// 更新 Redis
redisService.updateStockWithRedis(goodsInfo);
}
}
Jemter 测试,1秒2000个并发,订单服务两个负载:
18秒处理完这2000个请求,对于用户量小、普通下单来讲的开发这样是可以的,秒杀也可以,只不过是在此基础上添加上限流、Redis校验用户是否秒杀过就可以。
商品乐观锁更新库存的核心代码如下:
@Mapper
public interface GoodsInfoMapper extends BaseMapper<GoodsInfo> {
//乐观锁更新数据库
@Update("UPDATE tb_goods SET goods_stock =goods_stock-1,goods_sale=goods_sale+1, version = version+1 WHERE id = #{id, jdbcType = INTEGER} AND version = #{version, jdbcType = INTEGER}")
boolean updateStockByOptimistic(GoodsInfo goodsInfo);
}
分布式锁更新Redis缓存核心代码如下:
/**
* Redis操作Service实现类
*/
@Service
@Slf4j
public class RedisServiceImpl implements RedisService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
...其他方法
//Redis - Jedis连接池
@Autowired
private JedisPool jedisPool;
@Override
public void updateStockWithRedis(GoodsInfo goodsInfo) {
// Redis 多个写入操作的事务
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
// 开始事务
Transaction transaction = jedis.multi();
// 事务操作
this.decr(Constant.GOOOD_STOCK+ goodsInfo.getId(),1);
this.incr(Constant.GOOOD_SALE + goodsInfo.getId(),1);
this.incr(Constant.GOOOD_VERSION+ goodsInfo.getId(),1);
// 结束事务
List<Object> list = transaction.exec();
} catch (Exception e) {
log.error("updateStock 获取 Jedis 实例失败:", e);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
最后就是源码了:
本项目 SpringCloud 源码 https://gitee.com/android.long/spring-cloud-biglead/tree/master/biglead-api-11-admin
本项目 管理后台web 源码https://gitee.com/android.long/spring-cloud-biglead/tree/master/mall-admin-web-master
本项目 小程序 源码https://gitee.com/android.long/spring-cloud-biglead/tree/master/mall-app-web-master
如果有兴趣可以关注一下公众号 biglead ,每周都会有 java、Flutter、小程序、js 、英语相关的内容分享