Skip to content

Commit

Permalink
update 项目文档
Browse files Browse the repository at this point in the history
  • Loading branch information
hzcforever committed Jun 16, 2019
1 parent 579cecc commit f8663cf
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 5 deletions.
200 changes: 195 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,10 @@
- [页面级高并发秒杀优化](#页面级高并发秒杀优化)
- [商品列表页缓存实现](#商品列表页缓存实现)
- [热点数据对象缓存](#热点数据对象缓存)
- [商品详情静态化](#商品详情静态化)
- [秒杀接口前后端分离](#秒杀接口前后端分离)
- [解决超卖问题](#解决超卖问题)
- [服务级高并发秒杀优化](#服务级高并发秒杀优化)
- [集成 RabbitMQ](#集成-RabbitMQ)
- [Redis 预减库存](#Redis-预减库存)
- [RabbitMQ 异步下单](#RabbitMQ-异步下单)
- [Redis 预减库存和RabbitMQ 异步下单](#Redis-预减库存和RabbitMQ-异步下单)
- [第二次压测](#第二次压测)
- [写在最后](#写在最后)

Expand Down Expand Up @@ -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<GoodsVo> 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<Integer> 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

<div align="center"><img src="/img//second_to_list.png" width=""/></div>

通过 RabbitMQ 实现异步下单后,秒杀下单压测结果:/miaosha/do_miaosha

<div align="center"><img src="/img//second_do_miaosha.png" width=""/></div>

很明显两者的 QPS 有明显的提高,如果将 Redis 和 RabbitMQ 部署在不同的服务器上,提升效果可能会更加明显。

## 写在最后

通过这个秒杀项目对缓存有了更加深刻的理解,通过压测前后的对比更加直观地感受到了缓存的好处。通过 RabbitMQ 将业务逻辑异步化,并在高并发环境下有效地削峰,也能够大幅度地提升系统性能。

在后续的功能优化与扩展中,可以有以下几个思路进行深入思考:

1. 通过隐藏秒杀地址防止恶意刷新
2. 通过数学公式和图片验证码防止恶意刷新
3. 限制同一个用户在固定时间内访问秒杀接口的次数
3. 通过 Ngnix 配置负载均衡,给服务器分流
4. 通过 Redis 集群保证高可用
5. 开启 RabbitMQ 持久化机制保证消息队列的可靠性传输

Binary file added img/second_do_miaosha.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/second_to_list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit f8663cf

Please sign in to comment.