Java-基于redis 分布式锁形式和队列形式实现秒杀架构(配合消息队列)

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/Coder_Joker/article/details/83096759

通过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 好像上面的说法有问题,额,有空重新再看
  • 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();
		}
	}

响应是正常的

展开阅读全文

没有更多推荐了,返回首页