高并发商城秒杀项目总结

一. 项目概述

项目亮点:

  1. 使用分布式Session,实现多台服务器的Session共享
  2. 使用Redis缓存提高访问速度和并发量,减少数据库压力
  3. 通过三级缓冲过滤大量秒杀失败请求,减小后端服务压力
  4. 使用分布式锁防止商品超卖
  5. 页面进行静态化处理,加快用户访问速度
  6. 使用消息队列完成异步下单,实现流量削峰,提升用户体验
  7. 安全性优化:双重md5密码校验接口限流防刷

项目难点:
8. 并发量的提升
页面静态化和Redis高速缓存提升QPS,三级缓冲过滤大量请求,流量削峰避免请求量过大压垮后端服务
9. 超卖问题
数据库乐观锁悲观锁,Redis,Zookeeper分布式锁
10. 缓存击穿,缓存雪崩,缓存一致性

二. 用户模块

1. 模块功能

用户模块分为以下功能:

  1. 用户注册
  2. 用户登录
  3. 更改密码
  4. 从Session中获取用户信息

¥2. 用户登录功能流程

  1. Controller接收用户id(手机号)和密码
  2. 根据用户id获取用户信息(如果Redis缓存中有则从缓存中获取,没有则从数据库查出后放入Redis)
  3. 将传来的密码MD5加密(利用用户的Salt值)后与用户的密码比较是否一致
  4. 密码核对成功后,利用UUID生成token,在Response中添加值为token的Cookie,在Redis缓存中设置键为token,值为user的记录。Cookie的有效期和缓存过期时间一致(分布式Session)
  5. 返回值为token,传给前端
  6. 用户再次访问页面时根据Cookie中的token去Redis缓存中查找用户信息完成自动登录

¥3. 用户模块主要技术点

  • 两次MD5加密:在前端对输入的密码进行第一次加密,防止密码明文在网络被截取;在密码入库时进行第二次加密,防止数据库被盗后用户信息被窃取
  • 加密算法:将Salt打乱后与加密前密码拼成一个字符串,再进行MD5加密
String str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
return md5(str);
  • 分布式Session:用户登录成功后,在Redis中缓存用户信息,键为token,值为user;在Response中添加值为token的Cookie;后续可根据Cookie中传来的token去Redis中查询用户信息完成自动登录
  • JSR303数据校验:前端的用户id和密码通过LoginVO传递至后端,在LoginVO中通过JSR303先判断输入是否符合条件

三. 商品模块

1. 模块功能

商品模块主要有以下功能:

  1. 查询商品列表
  2. 查询商品详情

2. 商品查询流程

商品列表和商品详情查询的流程类似,主要如下:

  1. 从Redis缓存中尝试获取内容页面
  2. 缓存中有则直接返回,缓存中没有则从数据库中获取数据
  3. 根据模版和查询得到的数据利用thymeleafViewResolver手动渲染得到静态html页面
  4. 将静态页面缓存至Redis中
  5. 返回静态页面

¥3. 商品模块主要技术点

  • 缓存静态页面:提前生成商品列表和商品详情的静态html页面,并缓存至Redis中。用户访问商品页面时,直接从缓存中获取静态页面,一方面避免直接访问数据库,增大数据库压力;另一方面避免了动态渲染页面的开销

四. 订单模块

1.模块功能

订单模块分为以下几个功能:

  1. 查询订单是否存在
  2. 查询订单详情
  3. 下单

2. 下单流程

下单分为创建订单和创建订单详情两步,该两步定义为一个事务(@Transactional)。

¥3. 订单模块主要技术点

  • 唯一索引:为了保证订单的唯一性,防止用户重复秒杀,建立(用户id ,商品id)的唯一索引。
  • 分布式id:利用SnowFlack雪花算法生成订单id,保证在分布式状况下id的递增和唯一性
  • 缓存:前端会轮询是否下单成功,因此将订单和订单详情缓存在Redis中,减少数据库压力

五. 秒杀模块

1. 模块功能

秒杀模块主要分为两个功能:

  1. 秒杀
  2. 查询秒杀是否成功

2. 秒杀流程

秒杀流程通过RabbitMQ解耦为前部和后部,前部判断是否能够秒杀,后部进行秒杀订单的生成

前部流程:

  1. 先查询一级缓存,如果一级缓存中标识商品已秒杀完,直接返回
  2. 在Redis二级缓存中预减库存,如果库存小于零则去数据库中查询真实库存,获得真实库存后如果发现确实已卖完,则在本地缓存中标记该商品已卖完并返回
  3. 根据用户id和商品id查询是否重复下单
  4. 将用户信息和商品id包装成消息发送给消息队列
  5. 直接返回

后部流程:

  1. 后端服务从消息队列获取消息
  2. 判断库存是否小于零
  3. 判断是否重复下单
  4. 减库存
  5. 创建订单

其中减库存和创建订单必须定义为一个事务,且需要额外手段防止超卖

3. 秒杀模块主要技术点

  • 三级缓冲
  1. 本地缓存:HashMap<Long, Boolean>用于判断某个商品是否已秒杀完
  2. Redis缓存:Redis缓存缓存某个商品的库存量(与数据库中的真实库存不一定保持一致)
  3. RabbitMQ异步下单:防止超高流量击垮服务,利用RabbitMQ作流量削峰

通过三级缓冲保护,使得无法秒杀成功的请求尽早返回,减少服务和数据库的压力。其中前两级缓冲的初始化通过Spring Bean的初始化方法完成

  • 分布式锁防止超卖

为了防止超卖,尝试了各种方法:

  1. 单机锁(synchronize锁,ReentrantLock),该方法的缺陷是只能在单服务器情况下使用
  2. 数据库乐观锁,悲观锁
update sk_goods_seckill set stock_count = stock_count - 1 and version = version + 1 where goods_id = #{goodsId} and stock_count > 0 and version = #{version}

但是 stock_count > 0可能会锁住该表的其他行数据,影响整体的并发度

  1. Redis分布式锁和Zookeeper分布式锁
  2. AOP切面锁:利用AOP将锁上移至@Transactional外部,在事务提交后再释放锁,防止锁提前释放导致其他事务读取错误信息导致超卖
  • 注解+AOP锁:为了锁的外移和代码的复用,定义了一个@AopLock注解,配合一个AOP切面,该切面会在加了该注解的方法上进行AOP切入式加锁

六. 其他优化与未来优化方向

1. 接口限流

使用Guava的RateLimiter对接口进行限流,如果在规定时间内没拿到令牌,则直接返回

2. IP防刷

  1. 使用Guava的LoadingCache建立本地缓存,key为IP,value为RateLimiter
  2. 在访问防刷的接口时,通过Request获取IP地址
  3. 在LoadingCache会为第一次访问的IP分配一个RateLimiter
  4. 通过RateLimiter规定该IP每秒能访问的次数达到IP防刷的效果

3. 未来优化方向

  • 服务集群化:单台服务器难以承受压力时,需创建服务器集群,一方面提高并发量,一方面保证高可用。对应用服务器需设置负载均衡。
  • 微服务化:将上述的各个模块分离成一个个服务。实现服务间的解耦,进一步提升并发量
  • MySQL分库分表:当单表的数据量过大时,可考虑分库分表,但是需要涉及到分布式事务

七. 通用模块

1. 异常的设计与处理

  • 自定义一个运行时异常GlobalException,构造方法中传入CodeMsg
  • CodeMsg为枚举对象,在其中规定了错误码和异常信息
  • 通过@ControllerAdvice@ExceptionHandler设计全局异常捕获,捕获后根据CodeMsg返回异常原因

2. Redis Key前缀的设计

  • 设计一个KeyPrefix基类,主要有前缀名称和过期时间两个属性
  • 后续要使用Redis缓存时,为各类缓存项创建一个继承自KeyPrefix的前缀类,在其内部设置常量已供使用

八. 问题

1. Redis使用什么客户端,作了哪些配置?

  • 一般操作使用Jedis客户端,分布式锁使用Redisson
  • 配置了IP地址,端口号,连接池参数(最大阻塞等待时间,连接池最小空闲连接,连接池最大空闲连接,连接池最大连接数)

2. RabbitMQ作了哪些配置

最小的消费者数量
spring.rabbitmq.listener.simple.concurrency= 1
最大的消费者数量
spring.rabbitmq.listener.simple.max-concurrency= 10
指定一个请求能处理多少个消息,如果有事务的话,必须大于等于transaction数量.
spring.rabbitmq.listener.simple.prefetch= 1
是否启动时自动启动容器
spring.rabbitmq.listener.simple.auto-startup=true
决定被拒绝的消息是否重新入队;默认是true
spring.rabbitmq.listener.simple.default-requeue-rejected= true

发送重试是否可用
spring.rabbitmq.template.retry.enabled=true 
第一次和第二次尝试发布或传递消息之间的间隔
spring.rabbitmq.template.retry.initial-interval=1000 
最大重试次数
spring.rabbitmq.template.retry.max-attempts=3
最大重试时间间隔
spring.rabbitmq.template.retry.max-interval=10000
应用于上一重试间隔的乘数
spring.rabbitmq.template.retry.multiplier=1.5

2. 一般问题

  1. 缓存击穿和缓存雪崩以及缓存一致性等问题
  2. 大量的使用缓存,对于缓存服务器也有很大的压力,如何减少redis的访问(使用本地内存标记
  3. 在高并发请求的业务场景,大量请求来不及处理,甚至出现请求堆积的情况(水平拓展,增加消息队列的消费者服务)
  4. 怎么保证一个用户不能重复下单(唯一索引)
  5. 怎么解决超卖现象
  6. 页面静态化的过程及什么是浏览器缓存

3. 假如减了库存但用户没有支付,怎么将库存还原继续进行抢购

  1. 用户秒杀成功后,给RabbitMQ发送一个TTL消息(TTL为支付规定时间)
  2. 消息时间到后自动进入死信队列
  3. 死信队列的消费者获取订单消息,去数据库中查询订单是否已支付,若未支付则还原库存

4. 限流算法

漏桶与令牌桶算法的区别

漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率

在某些情况下,漏桶算法不能够有效地使用网络资源。因为漏桶的漏出速率是固定的参数,所以即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使某一个单独的流突发到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。通常,漏桶算法与令牌桶算法可以结合起来为网络流量提供更大的控制。

两者主要区别在于“漏桶算法”能够强行限制数据的传输速率,而“令牌桶算法”在能够限制数据的平均传输速率外,还允许某种程度的突发传输。在“令牌桶算法”中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限,所以它适合于具有突发特性的流量。


版权声明:本文为wanger61原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。