From f8663cf78184107cbe30d301d73740851d6e611c Mon Sep 17 00:00:00 2001 From: hzcforever <847445563@qq.com> Date: Sun, 16 Jun 2019 16:00:07 +0800 Subject: [PATCH] =?UTF-8?q?update=20=E9=A1=B9=E7=9B=AE=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 200 +++++++++++++++++++++++++++++++++++++- img/second_do_miaosha.png | Bin 0 -> 5564 bytes img/second_to_list.png | Bin 0 -> 5589 bytes 3 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 img/second_do_miaosha.png create mode 100644 img/second_to_list.png diff --git a/README.md b/README.md index 366965c..c4c71a8 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,10 @@ - [页面级高并发秒杀优化](#页面级高并发秒杀优化) - [商品列表页缓存实现](#商品列表页缓存实现) - [热点数据对象缓存](#热点数据对象缓存) - - [商品详情静态化](#商品详情静态化) - - [秒杀接口前后端分离](#秒杀接口前后端分离) - [解决超卖问题](#解决超卖问题) - [服务级高并发秒杀优化](#服务级高并发秒杀优化) - [集成 RabbitMQ](#集成-RabbitMQ) - - [Redis 预减库存](#Redis-预减库存) - - [RabbitMQ 异步下单](#RabbitMQ-异步下单) + - [Redis 预减库存和RabbitMQ 异步下单](#Redis-预减库存和RabbitMQ-异步下单) - [第二次压测](#第二次压测) - [写在最后](#写在最后) @@ -152,4 +149,197 @@ ## 页面级高并发秒杀优化 -待更... +这一节主要讨论使用页面优化技术来提升秒杀系统性能,即利用缓存最大程度地减少对用户数据库的直接访问,并解决超卖现象。 + +### 商品列表页缓存实现 + +最开始对于商品的查询优化是将 user 和 goodsList 直接加入到 model 中,然后通过动态渲染模板在浏览器端展示出来,接下来考虑如何做页面缓存。 + +先看修改后的代码: + + @RequestMapping(value = "/to_list", produces = "text/html") + @ResponseBody + public String toList(HttpServletRequest request, HttpServletResponse response, + Model model, MiaoshaUser user) { + model.addAttribute("user", user); + // 取缓存 + String html = redisService.get(GoodsKey.getGoodsList, "", String.class); + if (!StringUtils.isEmpty(html)) { + return html; + } + // 查询商品列表 + List goodsList = goodsService.listGoodsVo(); + model.addAttribute("goodsList", goodsList); + + // 手动渲染 + IWebContext ctx = new WebContext(request, response, request.getServletContext(), + request.getLocale(), model.asMap()); + html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx); + if (!StringUtils.isEmpty(html)) { + redisService.set(GoodsKey.getGoodsList, "", html); + } + return html; + } + +首先,查看 Redis 缓存中是否存在以 GoodsKey 为 key 的 String 类型的值,如果有且不为空则直接返回;否则通过 listGoodsVo() 查询商品列表,并放进 model 中,这个时候通过 ThymeleafViewResolver 手动渲染模板,如果得到的 html 不为空,则存入缓存(缓存的有效期可设为一分钟)。 + +其它相关页面的缓存实现以此类推,具体细节见源代码。 + +### 热点数据对象缓存 + +原先根据 id 取 user 的方法实现如下: + + public MiaoshaUser getById(long id) { + return miaoshaUserDAO.getById(id); + } + +显然每次取 user 都要通过 DAO 直接访问数据库,这里对该方法进行改进: + + public MiaoshaUser getById(long id) { + // 取缓存 + MiaoshaUser user = redisService.get(MiaoshaUserKey.getById, "" + id, MiaoshaUser.class); + if (user != null) { + return user; + } + // 取数据库 + user = miaoshaUserDAO.getById(id); + if (user != null) { + redisService.set(MiaoshaUserKey.getById, "" + id, user); + } + return user; + } + +还有更新密码的方法优化: + + public boolean updatePassword(String token, long id, String formPass) { + // 取user + MiaoshaUser user = getById(id); + if(user == null) { + throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST); + } + // 更新数据库 + MiaoshaUser toBeUpdate = new MiaoshaUser(); + toBeUpdate.setId(id); + toBeUpdate.setPassword(MD5Util.formPassFromDBPass(formPass, user.getSalt())); + miaoshaUserDAO.update(toBeUpdate); + // 删除和更新缓存 + redisService.delete(MiaoshaUserKey.getById, "" + id); + user.setPassword(toBeUpdate.getPassword()); + redisService.set(MiaoshaUserKey.token, token, user); + return true; + } + +当更新密码的时候,首先通过 getById(id) 取 user,如果为空则抛出异常;这里对于处理缓存与写库的顺序是先更新数据库再删除缓存(在网上有很多博客都讲的是先删缓存再写库,原因是如果先写库再删缓存,万一删除失败,这时会出现数据库与缓存数据的不一致),但是经过我的测试,也咨询了一些人,觉得处理缓存失败的概率要远远小于写库失败的概率,因此这里暂且使用先写库再删缓存的次序。 + +### 解决超卖问题 + +在高并发环境下,对于某一个共享变量的更新,很容易造成线程安全问题,在这里具体表现为超卖现象。 + +**超卖现象的解决思路:** + +在 SQL 语句中,加入条件判断语句,判断剩余库存是否大于0再去更新。 + + @Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId} " + + "and stock_count > 0") + int reduceStock(MiaoshaGoods g); + +由于数据库在每次更新的时候会对 miaosha_goods 加锁,因此更新其实是串行执行的,不会出现多个线程同时更新一条记录的情况,所以在这里是通过数据库来保证不会出现超卖现象。 + +在这里虽然解决了超卖现象,但仍然有一个问题,那就是同一个用户可能发出多个请求,也就是同一个用户秒杀到了多个相同商品。 + +**同一个用户秒杀多个相同商品的解决思路:** + +1. 通过验证码防止出现相关庆幸 +2. 在 miaosha_order 表中创建 user_id 与 goods_id 的唯一索引,并且通过 @Transactional 注解的 createOrder 方法中,先生成订单,后生成秒杀订单,如果出现了同一个用户发出的多个请求,则第二次及之后的秒杀请求会导致生成秒杀订单失败,从而引起事务的回滚,这里就能保证同一个用户只能秒杀一种商品一次。 + +## 服务级高并发秒杀优化 + +在前面的缓存优化中,我们考虑了如何最大程度地减少对数据库的访问,并解决了秒杀过程中可能出现的相关问题,在本节中我们进一步考虑如何减少对缓存的访问。 + +1. 通过 Redis 预减库存更进一步减少对数据库的访问 +2. 通过内存标记减少对 Redis 的访问 +3. 通过 RabbitMQ 将用户请求入队缓冲,实现异步下单,增强用户体验 +4. 第二次压测 + +### 集成 Rabbit MQ + +RabbitMQ 是采用 Erlang 语言实现 AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的消息中间件,它最初起源于金融系统,用于在分布式系统中存储转发消息。 + +RabbitMQ 整体上是一个生产者与消费者模型,主要负责接收、存储和转发消息。可以把消息传递的过程想象成:当你将一个包裹送到邮局,邮局会暂存并最终将邮件通过邮递员送到收件人的手上,RabbitMQ 就好比由邮局、邮箱和邮递员组成的一个系统。从计算机术语层面来说,RabbitMQ 模型更像是一种交换机模型。 + +在虚拟机上安装并配置 erlang 和 RabbitMQ,在 pom 文件中添加相关依赖,在本机浏览器中输入虚拟机 ip:15672 即可打开 RabbitMQ 的界面。 + +RabbitMQ 交换机有以下几种类型:fanout、direct、topic、headers 这四种,但 headers 类型的交换器性能比较差,一般不推荐。 + +### Redis 预减库存和RabbitMQ 异步下单 + +1. 在系统初始化的时候,把商品库存的数量预加载到 Redis 中(在 afterPropertiesSet 方法中实现预加载过程) +2. 在收到用户秒杀请求后,通过 Redis 预减库存,若库存不足则直接返回秒杀失败,并标记该 goods 已经秒杀完毕;如果库存大于0则入队,并返回正在排队中 +3. 请求出队,生成订单,减少库存 +4. 客户端轮询,判断是否秒杀成功 + +秒杀订单: + + @RequestMapping(value = "/do_miaosha") + @ResponseBody + public ResultUtil miaosha(Model model, MiaoshaUser user, + @RequestParam("goodsId") long goodsId) { + model.addAttribute("user", user); + if (null == user) { + return ResultUtil.error(CodeMsg.SESSION_ERROR); + } + + // 内存标记,减少对 Redis 的访问 + boolean over = localOverMap.get(goodsId); + if (over) { + return ResultUtil.error(CodeMsg.MIAO_SHA_OVER); + } + + // 预减库存 + long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId); + if (stock <= 0) { + localOverMap.put(goodsId, true); + return ResultUtil.error(CodeMsg.MIAO_SHA_OVER); + } + // 判断是否已经秒杀到 + MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId); + if (order != null) { + return ResultUtil.error(CodeMsg.REPEATE_MIAOSHA); + } + // 入队 + MiaoshaMessage mm = new MiaoshaMessage(); + mm.setUser(user); + mm.setGoodsId(goodsId); + sender.sendMiaoshMessage(mm); + return ResultUtil.success(0); // 0 表示排队 + } + +## 第二次压测 + +为了尽可能保证两次压测结果对比的公平性,内存、CPU配置保持不变,多变量配置文件与第一次压测完全相同。 + +### 商品查询与秒杀下单压测 + +加了页面缓存和对象缓存后,商品查询压测结果:/goods/to_list + +
+ +通过 RabbitMQ 实现异步下单后,秒杀下单压测结果:/miaosha/do_miaosha + +
+ +很明显两者的 QPS 有明显的提高,如果将 Redis 和 RabbitMQ 部署在不同的服务器上,提升效果可能会更加明显。 + +## 写在最后 + +通过这个秒杀项目对缓存有了更加深刻的理解,通过压测前后的对比更加直观地感受到了缓存的好处。通过 RabbitMQ 将业务逻辑异步化,并在高并发环境下有效地削峰,也能够大幅度地提升系统性能。 + +在后续的功能优化与扩展中,可以有以下几个思路进行深入思考: + +1. 通过隐藏秒杀地址防止恶意刷新 +2. 通过数学公式和图片验证码防止恶意刷新 +3. 限制同一个用户在固定时间内访问秒杀接口的次数 +3. 通过 Ngnix 配置负载均衡,给服务器分流 +4. 通过 Redis 集群保证高可用 +5. 开启 RabbitMQ 持久化机制保证消息队列的可靠性传输 + diff --git a/img/second_do_miaosha.png b/img/second_do_miaosha.png new file mode 100644 index 0000000000000000000000000000000000000000..3b0cbbdfdac1323eba37e892cdd74f4cad8f4add GIT binary patch literal 5564 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 literal 0 HcmV?d00001 diff --git a/img/second_to_list.png b/img/second_to_list.png new file mode 100644 index 0000000000000000000000000000000000000000..01aeff8e29adfafe14dd0bedf5d2a85f506ec670 GIT binary patch literal 5589 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 XQR)