本项目码云地址:https://gitee.com/flowers-bloom-is-the-sea/distributeNodeSolvePessimisticLockByRedis/tree/version3/
项目前身:https://gitee.com/flowers-bloom-is-the-sea/distributeNodeSolvePessimisticLockByRedis/tree/version2.0/
异步实现一人一单
一、问题描述
对于一人一单的秒杀,因为这个项目之前本来就是没做异步处理的,因此对于一个业务,对于锁的操作其实可以放到其他异步线程执行。这样子就可以提高效率,而对于可以全部放到异步线程执行的操作也是根据实际业务来看的,总不能把所有业务都放异步处理吧。
对于原来的项目的具体过程是:
对于单个用户访问想买一个物品时,是先到redis里获取锁,如果获取锁成功,那么就去执行订单创建的业务,反之失败。
本篇文章是对于单个用户访问进行访问加速的,也就是当用户来操作这个业务时,使用异步线程来执行其他的操作,使用异步线程操作前就返回数据给用户使得用户不需要等待太久。
二、优化过程
第一步:
对于创建每一个物品,都将物品的库存信息放到缓存里吧。
对应的controller:
@Autowired
private GoodsOrderService goodsOrderService;
@PostMapping("addGoods")
public R addGoods(@RequestBody Goods goods) {
goodsService.addGoods(goods);
return R.success(goods.getId());
}
对应的impl:
@Override
public void addGoods(Goods goods) {
save(goods);//1.save一下
//2.TODO 添加lua脚本
stringRedisTemplate.opsForValue().set("goods:stock:" + goods.getId(), goods.getStock().toString());
}
现在来测试一下:
`http://localhost:8081/goods/addGoods`
请求体:
{
"id":2,
"stock":100
}
预测:一个请求过去,正常情况下redis是有存储了该物品的库存信息了。另外数据库也添加一条id=2,stock = 100的物品信息了。
测试结果:
发现redis节点啊确实加入了库存信息
数据库信息:
id stock
------ --------
1 998
2 100
结果评价:和预期的一样。
第二步:
对于一个用户来想购买一个物品,那么可以先返回结果,后面的加锁的操作再使用异步来实现。
先来实现一下:
1、首先肯定是先来查询redis一下看看有没有库存,如果有库存,那么再查一下该用户是否已经购买了该物品,如已经买过了,那么直接返回给用户:已购买,不可以买2次。如果没买,那么就操作redis,异步操作库存扣减和订单处理任务,返回已购买。
先写一下lua脚本吧:
-- 1.参数列表
-- 1.1.物品id
local goodsId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'goods:stock:' .. goodsId
-- 2.2.订单key
local orderKey = 'goods:order:' .. userId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.用户-添加到-->hashset
redis.call('sadd', orderKey, userId)
return 0
感觉这里有漏洞,什么漏洞?如果有多个物品goods对吧,那么多个goods都要实现一人一单,假如说有2个物品,分别是:
goods1:{
id:1,
stock:1000
}
goods2:{
id:2,
stock:100
}
那么对于同一个用户,这个用户已经购买了物品1,接下来还想买物品2,
那么这个用户去查redis时,便会被判断已经买了物品2。这个不就是误判了吗,为什么会误判?觉得lua脚本有问题:
stockKey的定义是:goods:stock:goodsId
没问题对吧;
但是orderKey的定义有问题,因为orderKey定义为:goods:order:userId
这里不就是不能对唯一种类物品进行唯一标识吗?
应该这样设计orderKey定义:goods:order:goodsId:userId
。
经过分析,应该是可修改的
第三步:
对于要生成goodsOrder可以丢进一个队列里面,然后直接返回结果。也就是像下面一样:
@Override
public R buyOneGoods(Long goodsId, Long userId) {
Long result = stringRedisTemplate.execute(
GOODS_SCRIPT,
Collections.emptyList(),
goodsId.toString().intern(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return R.fail(r == 1 ? "库存不足" : "不能重复下单");
}
GoodsOrder goodsOrder = new GoodsOrder();
goodsOrder.setGoodsId(goodsId);
goodsOrder.setUserId(userId);
//先丢到队列里面
orderTasks.add(goodsOrder);
proxy = (GoodsOrderService)AopContext.currentProxy();
return R.success("buy one success");
}
交给队列后,队列的goodsOrder对象会被异步取出来,进行相应的处理:
因为在这整个业务开始处理的时候就有一个线程池开启一个异步线程来处理上面的goodsOrder
private static final ExecutorService GOODS_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct
private void init(){
GOODS_ORDER_EXECUTOR.submit(new GoodsOrderHandler());
}
这个线程创建的GoodsOrderHandler类里面就要一个方法专门处理goodsOrder的:
private class GoodsOrderHandler implements Runnable{
@Override
public void run() {
while (true){
try {
// 1.获取队列中的订单信息
GoodsOrder goodsOrder = orderTasks.take();
// 2.创建订单
handleGoodsOrder(goodsOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
private void handleGoodsOrder(GoodsOrder goodsOrder) {
Long userId = goodsOrder.getUserId();
// 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 获取锁
boolean isLock = lock.tryLock();
// 判断是否获取锁成功
if(!isLock){
// 获取锁失败,返回错误或重试
log.error("不允许重复下单");
return;
}
try {
// 获取代理对象(事务)
proxy.createGoodsOrder(goodsOrder);
} finally {
// 释放锁
lock.unlock();
}
}
因为run里有一个死循环,于是会不断地获取队列里的goodsOrder对象,然后再在handleGoodsOrder方法里完成订单和数据库库存扣减的业务。
全都配置好后再来测试一下:
最终测试:
先用postman测试接口:http://localhost:8081/goods/buyGoodByUserId/2/1
结果:
{
"code": 200,
"data": "buy one success"
}
查看一下数据库goods_order表:
id user_id goods_id
------ ------- ----------
7 1 2
goods表:
id stock
------ --------
1 998
2 99
再来查看一下缓存:
发现没问题!
再将redis里的用户购买记录去掉,在数据库里删除goods_order里的数据,将goods里物品id=2的库存恢复到100,对100库存使用jmeter配置100线程对/goods/buyGoodByUserId/2/1
测压:
对于数据库预测goods里的id为2的物品库存减1,goods_order里的数据新增一条,100线程只有1个通过。
上述的预测都成立,测试成功。
goods_order表:
id user_id goods_id
------ ------- ----------
8 1 2
聚合报告:
结果确实是只有1个通过。
这里的线程还是不敢开太多的,因为毕竟队列还是使用java内部自带的队列,性能肯定是比不上市面上成熟了的消息队列。
三、反思与总结
对于这个业务也就是使用了异步来处理其他请求,而对于也就是将同一个业务的其他操作放到异步线程里执行,就这样。
对于消息队列,可以使用比较成熟的框架比如其他rabbitmq、卡夫卡等,这里就写得随便了,不管了,有点意思就可以了。
另外,对于其他的方面,比如对于多个物品的一人一单还是要优化的。这就不管了,因为本文重点在于异步实现。