通过redis锁,list 以及配合消息队列实现商品普通下单流程和秒杀的架构设计
代码在这https://github.com/ItsFunny/spring-test/tree/master/spring-test-order-and-stock
测试代码都在测试文件下,若无sql则在dao中更改自己的代码即可,记得修改MQ和redis
更新日志:
-
2019-03-28 更:
- 不知道代码能不能运行,我也忘了…-.-,开始的时候是ok的,好像我后来改了点东西过
-
2019-01-23 更:
- 今天看原先的代码的时候发现还是有疏忽的地方的:
- 关于如何避免超卖问题:
- 原先的做法是通过在业务层的前部分先无锁通过redis判断库存(a)是否足够,如果足够之后会通过redis锁加锁再进行判断库存(a)是否足够,之后会减少,所以这里就是疏忽的地方,无锁检测并不能过滤大部分请求,压力还是会汇总于获取锁的阶段,
因而更好的做法是定义一个ThreadNotSafe的变量unsafeStock代替无锁检测,在业务层的上半部就进行--操作,这步操作能过滤大部分请求,之后后端在加锁对真正的stock--
,更改不知道会什么时候再更改,ide换成idea了,有点不习惯 - 11:37
好像上面的说法有问题,额,有空重新再看
- 原先的做法是通过在业务层的前部分先无锁通过redis判断库存(a)是否足够,如果足够之后会通过redis锁加锁再进行判断库存(a)是否足够,之后会减少,所以这里就是疏忽的地方,无锁检测并不能过滤大部分请求,压力还是会汇总于获取锁的阶段,
- 关于如何避免超卖问题:
- 今天看原先的代码的时候发现还是有疏忽的地方的:
-
2018-10-22 更:
- 忙于吉林长春两地跑,基本没怎么更新,今天更新了秒杀,采用的redis分布式锁实现的
- 不得不感叹简历10月才开始投真的是太太太晚了啊,大公司的都已经结束了…
- strategy+handler
前半段流程是与普通下单流程一样的:生成token->校验token,为了避免写重复的代码,将校验token抽到了aop中
-
2018-10-23 更:
- 添加了基于队列的方式,并且重新将order的操作给抽了出来(可以通过MQ,也可以直接DB插入),代码有点凌乱了,没好好整理过,git上已经更新
- 下一次更新应该会加入MQ,并且尝试使得MQ顺序化
2018-10-26 更:
这几天放松了下,代码没怎么敲,明天会加入MQ,不过加入MQ的话需要做成SOA了好像
基本的流程图为:
关于可能发生的几个问题:
1.重复下单问题:
1):用户不小心点多了,点击立即购买多次
2):网络延迟,用户刷新重复提交多次
3):用户恶意请求
解决方法:
a:前端按钮当点击完毕之后立马将其按钮变灰,防止无意请求
b:在点击生成商品页面的时候生成一个token
话不多说,直接敲代码,先搭建基本的环境吧
这是包结构图:
再真正开始之前,先说一下大体思路:为了模拟实际,我在test包下先init数据(直接循环插入数据),然后容器启动的时候加载到缓存中,random的方式获取商品信息,至于userid也同样采取random的方式
没有service,所以参数乐观的认为是不可能出错的!!!!实际是100%需要校验的
普通商品
- 第一个方案,按钮隐藏就不写了,直接写生成token的形式,
- 第一步,选择某个商品,并且点击了立即购买
@RequestMapping(value = "/buy")
public ModelAndView buy()
{
Random random = new Random(47);
int index = random.nextInt(OrderProductTestCache.PRODUCTS.size());
Product product = OrderProductTestCache.PRODUCTS.get(index);
ModelAndView modelAndView = new ModelAndView("buy_product");
// 生成token
long token = idWorkerService.nextId();
redisTemplate.opsForValue().set("product_buy_token_" + token, 0 + "",1000);
modelAndView.addObject("token", token);
// 随机挑选某个商品去买
modelAndView.addObject("buyStock", random.nextInt(10));
modelAndView.addObject("product", product);
return modelAndView;
}
生成的token很简单,是通过谷歌id生成器直接生成的
第二步:点击立即购买之后,总得提交订单吧(生成订单),但是在这之前需要进行校验
1)是为了防止重复提交表单
2)提交表单是需要预扣库存的呀,重复来了,都被你买完了咋整
//展示购买的商品信息
//预扣库存
@RequestMapping(value = "/doBuy")
public ModelAndView doBuy(HttpServletRequest request, HttpServletResponse response)
{
Map<String, Object>params=new HashMap<String, Object>();
ModelAndView modelAndView =null;
//token校验应该放在这,因为需要预扣库存
String token=request.getParameter("token");
if(!checkToken(token))
{
modelAndView=new ModelAndView("error");
modelAndView.addObject("error","无效请求,请重新购买");
return modelAndView;
}
//buyNum 和productId更应该是list类型,而不是单体,因为存在购物车购买的情况
//同样,只是为了演示的话,直接单个数据
int buyNum=Integer.parseInt(request.getParameter("buyNumber"));
long productId = Long.parseLong(request.getParameter("productId"));
//因为只是个demo,所以userId直接伪造的方式
long userId =Math.abs( random.nextLong());
Product product = productDao.findById(productId);
//先thread not safe 的方式校验一遍
if(product.getProductStock()-buyNum<0) {
params.put("error", "卖完了");
}else {
int validCount = productDao.decreaseStock(productId, buyNum);
if(validCount==1) {
//这里的步骤可以放在消息队列中
//并且以下这块操作需要用事务包裹,演示demo,就不添加service了
//生成订单
Order order=new Order();
order.setUserId(userId);
BigDecimal bigDecimal=new BigDecimal(product.getProductPrice()+"");
bigDecimal=bigDecimal.multiply(new BigDecimal(buyNum));
order.setOrderPayment(bigDecimal);
order.setOrderStatus(0);//0 新创建,未付款
orderDao.createOrder(order);
//生成订单详情
OrderDetail orderDetail=new OrderDetail();
orderDetail.setOrderId(order.getOrderId());
orderDetail.setProductId(productId);
orderDetail.setBuyNum(buyNum);
orderDetailDao.createOrderDetail(orderDetail);
params.put("orderId", order.getOrderId());
}else {
//失败,说明有多个买,并且卖完了了
params.put("error", "卖完了");
}
}
if(params.containsKey("error"))
{
modelAndView= new ModelAndView("error",params);
}else {
//跳转到支付页面
modelAndView=new ModelAndView("pay",params);
}
return modelAndView;
}
这是一坨代码,是的,演示demo嘛,就不拆出来了
接下来就是支付了,支付很简单,只需要修改状态即可
如果order_detail也有状态的话,需要另外独自修改哦,想想购物车买多个不同商品,然后收货日期不同的情况
@RequestMapping(value="/doPay")
@ResponseBody
public String doPay(HttpServletRequest request,HttpServletResponse response)
{
//调用接口
long orderId=Long.parseLong(request.getParameter("orderId"));
int validCount = orderDao.updateOrder(orderId, 1);
if(validCount<1)
{
return "fail";
}
return "ok";
}
但是我们发现万一用户提交订单但是不付钱咋办,解决方式是通过延迟的消息队列实现
所以我们还需要添加rabbitMQ的配置:
@Bean
public ConnectionFactory connectionFactory()
{
CachingConnectionFactory cachingConnectionFactory = new CachingConnectionFactory();
cachingConnectionFactory.setHost("localhost");
cachingConnectionFactory.setPort(5672);
cachingConnectionFactory.setUsername("guest");
cachingConnectionFactory.setPassword("guest");
return cachingConnectionFactory;
}
@Bean
public RabbitAdmin rabbitAdmin()
{
RabbitAdmin rabbitAdmin = new RabbitAdmin(connectionFactory());
rabbitAdmin.declareExchange(exchange());
return rabbitAdmin;
}
@Bean
public RabbitTemplate rabbitTemplate()
{
RabbitTemplate rabbitTemplate = new RabbitTemplate();
rabbitTemplate.setConnectionFactory(connectionFactory());
rabbitTemplate.setExchange("test_order");
return rabbitTemplate;
}
@Bean
public TopicExchange exchange()
{
return new TopicExchange("test_order");
}
@ConditionalOnBean(value=RabbitAdmin.class)
@Bean
public Queue nonConsumerOrderQueue()
{
Map<String, Object> args = new HashMap<String, Object>();
// 超时后的转发器 过期转发到 expire_order_queue
args.put("x-dead-letter-exchange", "test_order");
// routingKey 转发规则
args.put("x-dead-letter-routing-key", "order.expire");
// 过期时间 20 秒
// 这里设置过期时间,则内部的所有消息的过期时间是一致的,会覆盖消息的过期时间
args.put("x-message-ttl", 5000);
Queue queue =new Queue("nonconsumer_order_queue", false, false, false, args);
return queue;
}
@ConditionalOnBean(value=RabbitAdmin.class)
@Bean
public Binding queueBinding()
{
Queue queue = nonConsumerOrderQueue();
return BindingBuilder.bind(queue).to(exchange()).with("order.nonconsumer");
}
@ConditionalOnBean(value=RabbitAdmin.class)
@Bean
public Queue expireOrderQueue()
{
Queue queue = new Queue("expire_order_queue", true);
return queue;
}
@ConditionalOnBean(value=RabbitAdmin.class)
@Bean
public Binding expireQueueBinding()
{
return BindingBuilder.bind(expireOrderQueue()).to(exchange()).with("order.expire");
}
@Bean
public OrderTimerMessageConsumer consumer()
{
return new OrderTimerMessageConsumer();
}
注意空队列那个是不能有消费者,有消费者就无法做到延时的效果了:
接下来是我们处理的消费者:
@Service
@RabbitListener(queues =
{ "expire_order_queue" })
@Slf4j
public class OrderTimerMessageConsumer implements MessageConsumer
{
@Autowired
private OrderDao orderDao;
@Autowired
private OrderDetailDao orderDetailDao;
@RabbitHandler
@Override
public void process(AppEvent event)
{
log.info("[删除过期订单]收到消息:{}", event);
Order order = (Order) event.getData();
long orderId = order.getOrderId();
int validCOunt = orderDao.deleteByIdAndStatus(orderId, 0);
if (validCOunt >= 1)
{
log.info("[删除过期订单],删除了{}条数据", validCOunt);
int detailValidCount = orderDetailDao.deleteByOrderId(orderId);
log.info("[删除过期订单详情],删除{}条数据", detailValidCount);
}
}
}
其实是能改进的,这样做每次都需要到数据库中查询状态,数据库要崩溃咯,可以放到redis中,当然不是所有的属性都放到redis中,可以只判断是否为空即可(只需要在更新订单的时候记得delete key 即可)
秒杀
ok,普通商品我们是完成了,但是秒杀商品这样是不行的,秒杀的设计数据库统统不行,都需要放到缓存中来实现,并且还是差不多的流程,重复?程序猿最烦的就是重复的crud,ok我们优化:
//展示购买的商品信息
//预扣库存
@RequestMapping(value = "/doBuy")
public ModelAndView doBuy(HttpServletRequest request, HttpServletResponse response)
{
Map<String, Object>params=new HashMap<String, Object>();
ModelAndView modelAndView =null;
//token校验应该放在这,因为需要预扣库存
String token=request.getParameter("token");
if(!checkToken(token))
{
modelAndView=new ModelAndView("error");
modelAndView.addObject("error","无效请求,请重新购买");
return modelAndView;
}
//buyNum 和productId更应该是list类型,而不是单体,因为存在购物车购买的情况
//同样,只是为了演示的话,直接单个数据
int buyNum=Integer.parseInt(request.getParameter("buyNumber"));
long productId = Long.parseLong(request.getParameter("productId"));
//因为只是个demo,所以userId直接伪造的方式
long userId =Math.abs( random.nextLong());
ProductDTO product = productDao.findById(productId);
product.setBuyNum(buyNum);
//这就是优化的代码
productBuyService.killProduct(product, userId,params);
if(params.containsKey("error"))
{
modelAndView= new ModelAndView("error",params);
}else {
//跳转到支付页面
modelAndView=new ModelAndView("pay",params);
}
return modelAndView;
}
//这就是优化的代码
productBuyService.killProduct(product, userId,params); 将重复的步骤用这个代替,
相关的代码:
public interface ProductBuyService
{
void killProduct(ProductDTO product,long userId,Map<String, Object>res);
}
public abstract class AbstractProductSecKill implements ProductBuyService
{
private int type;
private AbstractProductSecKill nextHandler;
@Override
public void killProduct(ProductDTO product, long userId,Map<String, Object>res)
{
if (product.getLevel() == type)
{
this.doKillByDifLevel(product, userId,res);
} else if (this.nextHandler != null)
{
this.nextHandler.killProduct(product, userId,res);
} else
{
//可以抛出异常
return;
}
}
public abstract void doKillByDifLevel(ProductDTO product, long userId,Map<String, Object>res);}
他有2个子类:一个是NormalProductServiceImpl 一个是SecKillProductServiceImpl 从名字就可以看出是不同product
代码就是这么的suprise,迄今为止我们的普通商品下单的流程就结束了,秒杀的代码明天上课写吧…代码传git上了,请看顶部
2018-10-22 更:
忙于吉林长春两地跑,基本没怎么更新,今天更新了秒杀,采用的redis分布式锁实现的
采用策略和责任链:
前半段流程是与普通下单流程一样的:生成token->校验token
为了避免写重复的代码,将校验token抽到了aop中:
aop的处理:
全局异常没做,这个自个儿添加吧,其中一些固定的参数写到了constants包下,图上的懒得换了,心累(求职太晚的心累,哎)
之后则是相关逻辑:
AbstractProductSecKill#killProduct
先从redis查询,查询不到则从db中查询,这里也可以直接抽成接口,同时在实际项目中需要处理好缓存穿透(预设值,过滤)以及缓存雪崩(过期时间均匀分配,二级缓存,多机节点)问题
有2个子类:NormalProductServiceImpl 和SecKillProductServiceImpl,秒杀当然是sec的了:内部仅仅只是调用而已:
@Override
public void doKillByDifLevel(ProductDTO product, long userId, Map<String, Object> res)
{
this.instrantegy.kill(product, userId, res);
}
instrantegy是SecKillInstrantegy 的子类,做了一个,准备做第二个,第一个是分布式锁串行执行的形式:
具体逻辑:
@Override
public void kill(ProductDTO product, long userId, Map<String, Object> params)
{
long productId = product.getProductId();
String key = ProductConstants.SEC_PRODUCT_STOCK + product.getProductId();
long expireTime = System.currentTimeMillis() + 1000 * 60 * 3;
// 尝试等待3秒
if (myRedisService.tryLock(ProductConstants.SEC_PRODUCT_LOCK + productId, userId + ":" + expireTime, 3000))
{
try
{
// 占库存双重检测机制,直接用下面的取代
// 看的出来,这步其实有重复相同的操作,所以做法是在redis中新增一块,就单独product_id 对应库存的信息
Gson gson = new Gson();
product = gson.fromJson(redisTemplate.opsForValue().get(key), ProductDTO.class);
int stock = product.getProductStock();
if (stock - 1 <0)
{
params.put("error", "库存不足");
return;
}
product.setProductStock(product.getProductStock() - 1);
String json = gson.toJson(product);
redisTemplate.opsForValue().set(key, json);
// 生成订单,如果采用串行的方式,数据库操作不可以在这里进行,应该抛到消息队列中
log.info("userId{}生成订单", userId);
Order order = new Order();
order.setOrderId(idWorkerService.nextId());
order.setOrderStatus(0);
order.setUserId(userId);
order.setOrderPayment(new BigDecimal(100));
orderDao.createOrder(order);
params.put("orderId", order.getOrderId());
} finally
{
if (myRedisService.tryReleaseLock(ProductConstants.SEC_PRODUCT_LOCK + productId,
userId + ":" + expireTime))
{
log.info("userId:{}[成功释放锁]", userId);
} else
{
log.error("userId:{},[释放锁失败]", userId);
}
}
} else
{
params.put("error", "当前购买人数过多,请刷新再试");
}
}
Redis锁的代码:
public boolean tryLock(String key, String value, int waitTimes)
{
long startTime = System.currentTimeMillis();
Boolean res = false;
while (!res && System.currentTimeMillis() - startTime <= waitTimes)
{
res = stringRedisTemplate.execute(new RedisCallback<Boolean>()
{
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException
{
return connection.set(key.getBytes(), value.getBytes(), Expiration.seconds(120),
SetOption.SET_IF_ABSENT);
}
});
}
// 如果还是失败的话,则需要考虑过期时间
// 需要考虑的原因在于:这种情况:业务执行完毕之后某种原因删除失败,导致lock还需要被占用一段时间,很显然这是不能接受的
// 解决方法是额外在value中添加限定时间t1和超时时间t2,t1保证业务执行完毕后序线程得以继续,超时时间t2保证数据能够过期 ;
if (!res)
{
String tempJson = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(tempJson))
{
res = lockAgain(key, value);
} else
{
String[] arrs = tempJson.split(":");
long expireTime = Long.parseLong(arrs[1]);
if (expireTime < System.currentTimeMillis())
{
try
{
lock.lock();
// double check 这里的双重检测主要是为了防止其他线程
// 又获取到了锁,而却被这个线程给删了
tempJson = stringRedisTemplate.opsForValue().get(key);
if (!StringUtils.isEmpty(tempJson)
&& (expireTime = Long.parseLong(tempJson.split(":")[1])) < System.currentTimeMillis())
{
Boolean delete = stringRedisTemplate.delete(key);
if (!delete)
{
log.error("[删除过期key]失败,key为:{}", key);
} else
{
log.error("[删除过期的key]成功");
}
}
} finally
{
lock.unlock();
}
}
}
}
return res;
}
注意点:
1.双重检测,第一重检测发生在执行逻辑之前,先校验是否符合条件,第二重校验发生在获取锁之后,因为锁是随机获取的,不能保证当A获得锁的时候,pro还是那个pro
2.当获取锁失败的时候,需要判断是否超时,这种情况:业务执行完毕之后某种原因删除失败,导致lock还需要被占用一段时间,很显然这是不能接受的,解决方法是额外在value中添加限定时间t1和超时时间t2,t1保证了这种情况下锁得以释放,超时时间t2保证业务能执行完毕
3.建议:对于product,推荐新增 productId_stock的对应关系,因为从代码中可以看出有序列化和反序列化多次,所以可以考虑通过redis的increment来代替,简单又效率高
数据测试:
@Test
public void testManyThreads()
{
ExecutorService service = Executors.newFixedThreadPool(100);
long stTime = System.currentTimeMillis();
LinkedBlockingQueue<Integer>list=new LinkedBlockingQueue<>();
CountDownLatch countDownLatch = new CountDownLatch(10000);
for (int i = 0; i < 10000; i++)
{
final int userId = i;
service.execute(new Runnable()
{
@Override
public void run()
{
Map<String, Object> params = new HashMap<>();
productBuyService.killProduct(1L, userId, 1, params);
if (params.containsKey("error"))
{
System.out.println("userId:" + userId + "_error:" + params.get("error"));
}else {
list.add(userId);
}
countDownLatch.countDown();
}
});
}
try
{
countDownLatch.await();
log.info("[所有任务结束]总耗时:{}",System.currentTimeMillis()-stTime);
log.info("[总共有{}是成功购买到的]",list.size());
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
响应是正常的