# seckill_project **Repository Path**: master_st/seckill_project ## Basic Information - **Project Name**: seckill_project - **Description**: 电商秒杀项目 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2019-07-28 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # SpringBoot的秒杀项目 ## 一、项目简介 基于SpringBoot + MyBatis + Redis 搭建的Java电商秒杀系统. 项目实现的整体功能: 首先是用户登录, 用户登录使用的是手机号和密码, 用户登录成功之后就会跳转到秒杀商品的列表页面, 就会展示商品的图片, 名称, 原价, 秒杀价等信息. 然后会有个商品详情的按钮, 点击之后回跳转到单个秒杀商品详情页面, 会显示秒杀开始的时间, 秒杀是正在进行中or秒杀还未开始or秒杀已结束, 还秒杀商品的一些信息(价格, 图片等等). 如果秒杀正在进行中, 用户点击 "立即秒杀" 的按钮, 如果秒杀成功, 则会弹出是否查看订单的提示, 用户点击查看订单, 就会跳转到订单详情页面. 整个项目实现的功能流程大概就是这样. ## 二、项目初始化, 数据库表的设计 **1. 搭建项目环境** **初始化SpringBoot项目, 依赖所需要的starters, 在application.properties配置文件中进行 thymeleaf, redis, mybatis, 数据源的一些配置.** **2. 封装redis工具类** 引入Jedis jar包, 对redis进行一些相应的配置, 然后通过 JedisPool 来获取Redis连接, 然后通过封装好的工具类来获取redis连接. **3.数据库表的设计** \1. 用户表(miaosha_user) 字段: id, name, password, salt(varchar), register_time(注册时间). \2. 商品表(goods) 字段: id, goods_name, goods_img, goods_detail(商品详情), goods_price(商品价格), goods_stock(商品库存). \3. 秒杀商品表(miaosha_goods) 字段: id, goods_id(对应着商品表中的id), miaosha_price(秒杀价格), stock_count(秒杀商品的库存), start_time(秒杀开始时间), end_time(秒杀结束时间). **为什么要将商品表和秒杀商品表分开存储??** 如果将秒杀商品也存放在商品表中, 那么商品表就需要有一些额外的字段, 比如秒杀价格, 秒杀开始时间, 秒杀结束时间, 秒杀商品的库存, 但是秒杀活动只是在一段时间内的少部分商品才进行的, 所以对于大部分商品来说, 这些字段都是空的, 无用的, 而大量的无用字段会造成整个商品表繁琐和复杂, 并且在查询插入操作中都会增加一定的开销, 所以单独建立一张秒杀商品表, 不会影响整体商品表的使用. 对两个表的操作也更加简洁高效. \4. order_info(订单表) 字段: id, user_id, goods_id, delivery_addr_id(收货地址表行数据的id), goods_name(商品名称), goods_count(购买的商品数量), goods_price(商品价格), status(订单状态), create_date(订单创建时间), pay_date(订单支付时间). \5. miaosha_order(秒杀订单表) 字段: id, user_id, goods_id, order_id. **将秒杀订单表单独拎出来, 这次仅仅是做一个秒杀活动, 下次如果再做扩展, 促销活动, 如果只有一张订单表, 就需要修改表结构, 这样是不可取的, 所以就将秒杀订单表单独拉出来, 有利于相互之间的解耦和扩展.** ## 三、登录功能 **首先在项目中是封装了一个返回给前端的响应类(Result类), 这个类里面有code(响应状态码,代表当前响应是失败还是成功), msg(表示对当前响应的描述信息), data(返回给前端的数据)** **1. 两次MD5** 为了避免用户登录的时候, 用户密码在网络中明文传输, 前端先对用户输入的密码进行一次MD5 + 固定salt 加密, 就是用户点击 "登录" 按钮的时候, 前端在发送AJAX请求之前, 会先对用户输入的密码拼接上一个固定的salt值, 然后再对这个数据进行第一次加密, 是将进行加密之后的数据传输给后端. 后端接收到前端请求的数据, 会先对数据进行校验, 比如手机号是否为null, 是否符合正则, 密码是否为空等. 如果用户输入合法, 则先根据用户的手机号从数据库中查询出MiaoshaUser信息, 查询到MiaoshaUser信息之后, 根据这个对象, 可以得到用户在数据库中存储的密码 + 随机salt值, 然后将当前传入的密码+salt值之后进行MD5加密, 再与数据库中的存储的密码进行比较, 如果相等则登陆成功. 再做一次随机salt加密是为了防止数据泄露之后暴力穷举破解用户密码. **2. 封装Redis工具类和key工具类** 使用了模板方法模式. 模板方法就是定义一个模板结构, 将部分不同的实现放到具体的子类去实现, 将相同部分的代码放到抽象父类中. 可以通过增加不同的子类来增加不同的方法. ```java //定义一个模板接口 public interface KeyPrefix { public int expireSeconds(); public String getPrefix(); } //所有子类的抽象父类 //不同的key的子类都要继承这个抽象父类 public abstract class BasePrefix implements KeyPrefix{ private int expireSeconds; private String prefix; public BasePrefix(int expireSeconds,String prefix){ this.expireSeconds = expireSeconds; this.prefix = prefix; } //0代表永不过期 public BasePrefix(String prefix){ this.expireSeconds = 0; this.prefix = prefix; } @Override public int expireSeconds() {//默认0代表永不过期 return expireSeconds; } @Override public String getPrefix() { String className = getClass().getSimpleName(); return className+":"+prefix; } } //不同key前缀子类具体的实现 public class MiaoshaUserKey extends BasePrefix{ //Redis中key的默认过期时间为两天 public static final int TOKEN_EXPIRE = 3600*24 * 2; //过期时间默认为0,代表永不过期 private MiaoshaUserKey(String prefix) { super(prefix); } public MiaoshaUserKey(int expireSeconds, String prefix){ super(expireSeconds, prefix); } public static MiaoshaUserKey token = new MiaoshaUserKey(TOKEN_EXPIRE,"tk"); public static MiaoshaUserKey getByName = new MiaoshaUserKey("name"); } ``` 在实现Redis工具类的过程中, 遇到了循环依赖的问题. 在创建RedisServcie的工具类的时候, 需要将JedisPool注入到RedisService类中, 但是在RedisService类中又使用@Bean生成了JedisPool这个Bean. 这块就产生了循环依赖, JedisPool的创建时依赖RedisService的, RedisService里面又注入了一个JedisPool, 所以这两个之间产生了循环依赖. ```java @Service public class RedisServer { //将JedisPool对象注入到Spring容器中去 @Autowired JedisPool jedisPool; @Bean public JedisPool JedisPoolFactory(){ JedisPoolConfig poolConfig = new JedisPoolConfig(); poolConfig.setMaxIdle(redisConfig.getPoolMaxIdle()); poolConfig.setMaxTotal(redisConfig.getPoolMaxTotal()); poolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait() * 1000);//毫秒 JedisPool jedisPool = new JedisPool(poolConfig,redisConfig.getHost(),redisConfig.getPort(), redisConfig.getTimeout()*1000); return jedisPool; } } ``` 解决方法就是, 将 JedisPool 的创建提取出来, 然后使用@Service注解进行标注为一个Bean, 然后再使用@Autowired注解将JedisPool注入, 就可以解决循环依赖问题. ```java @Service public class RedisPoolFactory { @Autowired RedisConfig redisConfig; //生成JedisPool Bean @Bean public JedisPool JedisPoolFactory(){ JedisPoolConfig poolConfig = new JedisPoolConfig(); poolConfig.setMaxIdle(redisConfig.getPoolMaxIdle()); poolConfig.setMaxTotal(redisConfig.getPoolMaxTotal()); poolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait() * 1000);//毫秒 JedisPool jedisPool = new JedisPool(poolConfig,redisConfig.getHost(),redisConfig.getPort(), redisConfig.getTimeout()*1000); return jedisPool; } } @Service public class RedisServer { //将JedisPool对象注入到Spring容器中去 @Autowired JedisPool jedisPool; } ``` **3. 分布式Session的实现** 分布式Session解决方案: 使用Redis充当独立的Session服务器. (1)用户登录成功之后, 使用UUID生成一个token, 然后通过前缀+token的方式作为key, 将MiaoshaUser序列化为JSON格式的字符串作为value, 存储在redis中. 并设置有效期为两天. 然后以这个token作为value, 向浏览器端发送一个Cookie. 同样设置Cookie的有效期为两天. (2)实现了一个拦截器. 拦截器的实现就是实现HeadlerInterceptor接口, 然后重写拦截器的方法, 最后实现WebMvcConfigurerAdapter接口, 注册拦截器. 拦截器首先会拦截用户的所有请求, 得到浏览器端的Cookie, 就可以找到token, 通过这个token从redis中查询出对应的用户信息, 然后将这个MiaoshaUser对象存储到ThreadLocal中(使用hostHoldler封装了ThreadLocal), 这块需要活跃Session, 就是每当用户请求访问一次, 都应该将Cookie的有效期设置为两天, 就是再调用生成Cookie的方法, 生成一模一样的Cookie, 就可以重置Cookie的有效期. 并且重新向redis中写入key的有效期. **####应用程序并没有存储Session信息, 而是将Session信息存储在redis中, 通过Cookie标识用户, 从redis中获取session信息, 就完成了分布式Session的实现.** ## 四、秒杀功能的实现 **1. 商品列表页** 用户登陆成功之后, 会跳转到商品列表页面. 首先封装Goods和MiaoshaGoods的字段, 封装成一个GoodsVO对象. 从数据库中查询出GoodsVO对象, 将查询出来的List放在Model对象中, 然后渲染 "goods_list" 模板, 然后返回给浏览器. 查询GoodsVO对象, 数据库语句使用了一个左连接(left join). ``` //对miaosha_goods和goods做一个左连接 SELECT g.*,mg.miaosha_price,mg.stock_count,mg.start_date,mg.end_date FROM miaosha_goods mg LEFT JOIN goods g ON mg.goods_id = g.id; ``` **2. 商品详情页** 当在goods_list页面点击 "详情" 按钮时, 会访问商品详情的url, 根据传入的goods_id从数据库查询出对应的GoodsVO对象, 然后从 ThreadLocal中取出当前MiaoshaUser对象, 将这两个add进Model中, 可以得到秒杀商品的开始时间和结束时间, 然后根据当前时间计算出秒杀是否开始 or 正在进行中 or 已结束. 用不同的status来标识秒杀的状态, 然后添加到Model对象中, 渲染goods_detail模板, 然后返回给浏览器. 如果秒杀还未开始, 需要进行倒计时, 就是要计算出计算出距离秒杀开始还有多长时间, 然后返回给模板. 前端接收到数据, 会进行秒杀的倒计时. **3. 秒杀功能的实现** 点击 "立即秒杀" 的按钮, 会请求到后端对应的url, 首先从请求中得到goods_id, 根据这个goods_id从数据库中去查询商品的库存, 然后使用userId和goods_id去数据库中查询出是否存在秒杀订单, 如果已存在, 则说明此用户已经秒杀过了, 不能重复秒杀. 如果没有秒杀过商品, 则进行秒杀的处理逻辑. **秒杀的逻辑:** **秒杀的三个步骤:** **(1)减库存(2)下订单(3)写入秒杀订单** **整个秒杀过程使用事务注解来实现.** **最开始实现秒杀的时候直接通过Dao对数据库来进行操作.** **在Service中开启一个事务, 先减秒杀商品的库存, 然后再创建订单, 再创建秒杀订单. (这两个订单都是存放在数据库中的). 订单的创建和秒杀订单的创建也在一个事务里面实现.** **然后返回OrderInfo订单信息, 最后将订单OrderInfo对象add进Model中, 渲染模板order_detail, 然后返回给浏览器.** ## 五、使用JMeter对当前秒杀模块进行压测 主要是使用 JMeter 对模块的 URL 的 QPS 进行压测, QPS就是系统的吞吐量, 也就是每秒查询数. 体现了单位时间内系统处理请求的能力. \1. 对商品列表页进行压测(/goods/to_list) (1)第一次将并发请求数设置为1000, QPS最大能达到70多80, 性能很差, 能够支持的并发很低. (会生成聚合报告和图形) (2)第二次将并发请求数设置为2000, QPS最大可达到120多, 性能同样很差. 因为是对商品列表接口进行压测, 在GoodsController中只做了数据库的查询操作, 所以推测性能瓶颈出现在数据库上. (3)第三次将并发请求数增加到10000, 然后进行测试, 通过任务管理器可以观察服务器CPU使用情况, 查看进程所占用的CPU资源, 内存资源的情况. 当请求到达2000多的时候, CPU不断升高, 查看进程, 发现mysql进程占用了大量的CPU资源, 也就是说性能瓶颈出现在mysql上. 如果是在linux系统下, 使用top命令, 应该可以观察到系统的负载会不断升高. 且mysql也是占用大量的CPU资源, 成为系统的性能瓶颈. ![img](https://note.youdao.com/yws/public/resource/e668857a837755960e74ed44180a3120/xmlnote/29A0A268173A4166892A40F6ABCFD122/30515) (5)对用户接口压测都是10000个并发请求 添加一个UserController对获取用户信息接口进行压测, (就是建立线程, 传过来的参数会带一个token值) QPS可能达到300多400, 是之前的三倍左右, 并且CPU较之前相比没有那么高, 数据库也没有占用太多的CPU资源, 是因为获取用户信息只读取了一次缓存. 但是上面对用户接口的压测不准确, 因为只有一个token, 所以只模拟了一个用户登录. 为了模拟多个用户, 添加一个配置文件, 配置多个token, 模拟多个用户请求, 引用这个配置文件, 做压测时会从配置文件中读取值传送给客户端. QPS最大能够达到600多. ## 六、页面级高并发秒杀优化(缓存技术) **1. 页面缓存** 对商品列表页面做缓存. (/goods/to_list) 之前是先从数据库中查询出秒杀商品信息, 将GoodsVO添加到Model中, 然后渲染goods_list模板, 返回给客户端. 页面缓存的步骤: (1)取缓存 (2)手动渲染模板 (3)存储到redis中, 下次访问页面先访问缓存, 如果缓存失效, 再从数据库中查询, 渲染完模板, 再将页面缓存起来. 页面缓存的逻辑: 当访问/goods/to_list页面时, 首先从redis缓存中取出页面, 如果不为空, 则直接返回. 如果为空, 先从数据库中查询出GoodsVO秒杀商品的信息, 然后使用ThymeleafViewResolver手动渲染模板, 返回的是String类型的数据, 如果数据不为空, 将String类型的html保存到redis中, 并设置有效期为60秒, 最后返回这个String类型的html页面. 做页面缓存是为了导致短时间内高并发的访问量导致服务器压力过大. 并且避免了每一次的请求都去查询数据库, 减轻数据库的负载. 给页面缓存也需要设置有效期, 设置60秒的时间, 对大部分情况下是可以容忍的, 并不会造成太大的影响. 页面缓存的缓存时间一般也都设置比较短, 并且是在短时间内变化不大的数据, 比如商品列表. 对页面进行测试, 也就会发现在redis中会存储html字符串数据. 并发请求数: 5000*10 对页面再进行一次压测, 会发现占用CPU资源最多的是Java进程和redis进程, mysql进程已经明显不见了, CPU也比之前低了很多. QPS是2000多, 可以达到之前的一倍多. 之前的mysql进程负载也非常高. **2. 对象缓存** 之前用户登录的逻辑: 用户第一次登录, 会先从数据库查询用户的信息, 然后与用户输入数据进行对比, 如果相等则登陆成功, 将用户信息存储在redis中, 并向客户端发送Cookie, 然后用户在有效期内访问, 就直接从redis中查询用户信息, 将MiaoshaUser对象放在ThreadLocal中, 其他业务模块也可以访问的到. 如果token和redis中的用户数据失效的话, 用户登录, 还是需要查询一次数据库. 对象缓存就是在这块进行了优化. 这块首先不进行数据库的查询, 而是先从缓存中查询该用户信息, 因为是MiaoshaUser对象, 所以只要用户信息不变化, 缓存是永久有效的. 如果缓存中可以查询到User信息, 则返回, 如果查询不到用户信息, 再从数据库中查询用户信息, 然后将用户信息回填到缓存中. 对象缓存只要不发生变化, 就是永久有效. (1)用户对象缓存和数据库一致性 **如何保证缓存数据库的一致性?先更新缓存还是先更新数据库?** 添加了一个updatePassword()方法, 当用户更新密码的时候, 先更新数据库中MiaoshaUser对象的信息, 然后还需要更新redis缓存中的token缓存和对象缓存, 再将对象缓存删除, token缓存不能删除, 更新一下token缓存, 将新的MiaoshaUser对象序列化之后重新添加到缓存中. 就是先更新数据库, 再更新缓存. 反过来不可以. 如果一个写操作, 先使缓存失效, 这时候过来一个并发的读操作, 会从数据库中去查询数据, 然后在回填到缓存中, 这些写操作更新了数据库, 数据库与缓存中的数据就会不一致. 而先操作数据库再更新缓存就不会出现这种情况. **3. 商品详情静态化(相当于前后端分离)** 定义一个GoodsDetailVO对象. 将goods_detail页面放在static目录下, 从goods_list页面跳转到goods_detail页面, 不再是直接访问后端的url路径, 而是直接跳转到 goods_detail.htm再加上goods_id参数. 跳转到 goods_detail.htm静态页面. 然后获取发送一个异步的请求去请求后端的数据, 服务端从数据库中查询出GoodsDetailVO对象, 然后返回JSON格式的数据, 前端进行数据的展示. **4. 秒杀静态化** 之前的秒杀模块的逻辑是: 在goods_detail页面点击 "立即秒杀" 按钮. 会请求后端秒杀接口的url, 然后后端处理完秒杀的逻辑之后, 返回order_detail模板.(就是订单详情) 秒杀接口之前进行压测, 并发请求数5000*10, QPS是1300多. **优化:** 将 "立即秒杀" 按钮做成一个AJAX异步请求, 将goods_id作为参数传过来. 进行秒杀逻辑的处理, 当处理完毕之后, 不再返回模板, 而是将orderInfo的信息返回JSON格式数据. 将order_detail.htm也放在static目录下静态化, 跳转到order_detail.htm页面. 将order_id作为参数传递给order_detail.htm, 然后依据这个order_id去数据库查询OrderInof信息. 在Spring的配置文件中增加静态文件的配置. 主要配置就是启用缓存, 服务端告诉客户端缓存页面的时间(项目配置的是60秒). 这样再访问页面时, 就访问的是浏览器缓存. **5. 订单详情静态化** 将order_detail.htm页面存储到static目录下, 然后秒杀成功后, 会返回order_id, 并且请求order_detail.htm静态页面, 并携带order_id参数. 采用ajax的方式向服务端请求根据order_id查询OrderDetail信息, 并显示在浏览器上. **6. 解决超卖问题** (1)解决不同用户对同一个商品的多次秒杀 当两个用户并发进行减库存的时候, 会将库存减为负数. 解决方法是: 给SQL语句后面加上判断语句 stock_count > 0 就可以保证商品库存不会减为负数. 本身也是利用的数据库锁来实现. 当数据库进行update, insert等操作时会加上排他锁. 来保证数据操作的安全性. ``` update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId} and stock_count > 0 ``` (2)解决同一个用户对同一个商品的多次秒杀 一个用户只能秒杀同一个商品一次. 为了防止秒杀多次商品, 首先要判断是否已经有此用户的秒杀订单, 使用user_id和goods_id去查询数据库中是否存在对应的秒杀订单, 如果存在, 则说明已经秒杀.过此商品. 到那时这样做可能存在问题, 比如用户第一次点击秒杀, 此请求正在处理中, 还未生成秒杀订单, 该用户再次点击立即秒杀, 这是可能就会多次秒杀到该商品. 为了防止这种情况, 需要给miaosha_order(秒杀订单表)添加唯一索引, 唯一索引由user_id和goods_id组成. 就避免了上述这种情况. **在这块需要着重关注一下事务的隔离级别和传播行为.** **这块需要关注可以利用redis分布式锁来实现.** **7. 判断用户是否已经秒杀** 就是查询秒杀订单是否已经存在, 不从数据库中查询, 直接从缓存中查询, 相当于一个小小的优化. 在秒订单创建好之后, 将秒杀订单写入缓存中, 以user_id和goods_id做为key. **8. 进行压测, 判断是否存在超卖情况** 对 /miaosha/do_miaosha 接口进行压测, QPS和之前的相当, 如果是在linux系统下, 系统负载应该较高, 但是现在不会出现超卖问题. 在 5000*10 的并发量下, 系统QPS1100多, CPU高, 系统负载比较高. ## 七、秒杀接口的优化 **1. 为什么要使用消息队列?** (1)实现消息处理的异步化. 将用户的秒杀请求封装成MiaoshaMessage之后, 再经过序列化, 放入到MQ中, 然后就可以返回给用户 "正在排队" 的消息了. 将秒杀请求的处理由同步变为异步, 加快系统的响应速度, 提高系统的吞吐量. (2)流量削峰 在秒杀等场景中, 会因为瞬间的巨大流量, 服务器承载不住高并发的请求而挂掉, MQ可以缓解在短时间内的巨大流量, 减轻服务器的压力. 之前的用户请求再进行逐渐的过滤之后, 才将消息放入消息队列中, 最终落到数据库上的请求是很少的, 所以有效的保证了数据库不会挂掉. 许多无用的请求已经被拦截在数据库之前, 落到数据库上的大部分都是有效请求. 秒杀是一个读多写少的场景, 所以应该有效的使用缓存. p就是消息生产者, 红的就是消息队列, c就是消息消费者. **8. 对秒杀接口的优化** **思路: 减少对数据库的访问, 将同步下单改为异步下单** **(1) 系统初始化, 把商品数量加载到redis中** MiaoshaController实现 InitializingBean 接口, 实现 afterPropertiesSet方法, 这个方法就是对系统初始化, 从数据库中查询出所有的秒杀商品, 然后将这些秒杀商品的goods_id拼接上一个前缀, 以商品库存作为value, 将这个秒杀商品库存缓存到redis中, 并且创建一个Map, 用来标记商品是否秒杀完毕, 在 afterPropertiesSet 方法中将商品库存缓存到redis中时, 以商品goods_id为key, 以boolean类型为value, 存储到Map中, 相当于对秒杀商品做了是否秒杀完毕的标记. 在判断商品秒杀是否结束时, 先从Map中查询对应的值, 如果秒杀商品已经结束, 则直接返回, 否则再查询redis缓存. 这样可以有效减少对缓存的访问. **(2) 收到请求, redis预减库存, 库存不足, 直接返回.** 在收到用户秒杀请求之后, 并不是先去查询数据库, 而是先访问内存标记Map, 判断商品是否秒杀结束, 如果未结束, 则redis预减库存, 返回减去1之后的所剩数量. 这块需要注意redis减库存是否是线程安全的? redis为什么是线程安全的? 为什么redis是单线程应用效率还那么高? 然后判断返回的数量是否大于0, 如果小于0, 则秒杀失败, 将内存标记Map中对应的goods_id的value设置为true, 代表此商品已经秒杀失败. 大于等于0, 则可以进行秒杀, 然后再判断用户是否已经秒杀过此商品, 就是从缓存中查询是否已经有此用户的秒杀订单, 如果有说明用户已经秒杀过, 如果没有则可以进行秒杀. 这块需要注意的问题是: 如果此处存在并发, 就是用户第一次点击 "立即秒杀" 按钮之后, 系统还正在秒杀处理流程中, 还未生成买秒杀订单, 此时用户再次点击 "立即秒杀", 这时并不能从缓存中查询到对应的秒杀订单, 则会进入秒杀流程, 但是在之前的数据库表的设计中, 向miaosha_order中添加了唯一索引, 所以这块插入数据库并不会成功, 还是可以防止超卖问题. **(3) 请求入队, 立即返回排队中.** 之前的判断都合法, 就可以进行秒杀逻辑了. 创建MiaoshaMessage类, 用来封装用户的秒杀请求信息, 将MQSender生产者注入进来, 然后将MiaoshaMessage进行序列化之后, 通过MQ生产者发送消息队列中, 然后当前线程返回正在排队中. 注意这块不能直接返回秒杀成功, 因为只是将秒杀请求封装放入到消息队列中, 并不能判断是否秒杀成功. 所以只能返回客户端 "正在排队中". 是怎么封装MiaoshaMessage的呢? ```java public class MiaoshaMessage { private MiaoshaUser user; private long goodsId; public MiaoshaUser getUser() { return user; } public void setUser(MiaoshaUser user) { this.user = user; } public long getGoodsId() { return goodsId; } public void setGoodsId(long goodsId) { this.goodsId = goodsId; } } ``` **(4) 请求出队, 生成订单, 减少库存.** MQReceiver消费者从消息队列中获取到消息, 然后取出消息先进性反序列化成MiaoshaMessage, 然后查询数据库, 判断是否还有库存, 这块是可以查询数据库的, 因为只有少量的请求能够过来, 所以不会对数据库造成太大的负载. 并且从数据库中查询出来的数据是最可靠的. 如果商品库存大于0, 则可以进入秒杀的处理流程. 将减库存, 下订单开启一个事务操作, 下订单又分为插入订单信息和插入秒杀订单信息, 这两个操作也放在一个事务中. miaosha()方法逻辑, 减少库存, 更新库存. 然后创建订单. 有可能减库存失败, 当减库存失败, 则向redis中添加一条数据, 以当前goods_id为key, 以boolean类型的值为value, 用来标识当前秒杀商品已经秒杀失败. 这块是为了之后的轮询操作可以进行是否秒杀失败. **(5) 客户端轮询, 是否秒杀成功.** 在MiaoshaController中, 如果之前的预减库存, 判断用户是否已经秒杀验证过通过, 将用户秒杀请求封装之后发送到消息对列中, 此时返回给客户端 "排队中".(这个是JSON格式的数据) 前端页面goods_detail.htm接收到 "排队中" 的消息, 会做一个轮询. 给用户显示 "处理中" 的消息, 然后就是调用AJAX请求, 异步的对服务端 /miaosha/result 接口请求来获取秒杀结果. 根据服务端返回的整型状态标志来判断1.是否秒杀成功 2.是否秒杀失败3.是否还在排队中 服务端判断秒杀是否成功: \1. 从缓存中查询秒杀订单. 如果秒杀成功, 返回orderId, 订单id. 客户端会显示 "是否查看订单", 如果点击查看, 会将order_id请求带上跳转到order_detail.htm页面, 然后异步请求服务端, 获取OrderInfo信息. 然后将查询出来的订单信息, 商品信息封装成OrderDetailVO对象, 返回给客户端JSON格式的数据. \2. 如果没有查询到秒杀订单, 则可能出现两种情况. (1)秒杀失败 从redis缓存中查询当前商品goods_id对应的value是否为true, 代表是否秒杀失败, 如果秒杀失败, 则返回状态码为-1, 代表秒杀失败, 客户端显示对应的信息. (2)系统流程正在处理中. 客户端需要继续轮询. 如果从缓存中查询到的不是秒杀失败, 则系统正在处理中, 就返回客户端 0 状态码, 客户端继续轮询, 将轮询间隔设置为200毫秒. **7. 对优化之后的秒杀接口进行压测** 并发请求量 5000*10 5000个线程跑了10次 这块还配置了token文件, 而且还需要再提前向数据库中插入用户信息, 编写UserUtil工具类, 向数据库插入5000个用户信息, 利用JMeter模拟5000个用户的并发请求. 测试的是 /miaosha/do_miaosha 接口. 可以看到redis进程占用的CPU资源最多, 将近10%, mysql的负载非常小, 同样可以看到CPU的负载上升的很快, CPU很高. (如果是linux, 系统负载可能达到20多). 这次的QPS为1200多, 比之前提升了一点, 但是效果不明显. 分析: 进行压测的效果不明显, 应该是服务器的性能跟不上, 一台双核的CPU服务器, 开启了Java进程, redis进程, mysql进程, 还有并发的5000个线程, 效果不明显是很正常的. 第二次再进行压测, QPS明显比优化之前提高了, 虽然提升了不到一倍, 但是效果还有较为明显的. ## 八、安全优化 **1. 秒杀接口地址隐藏** 如果将秒杀地址写死, 每次请求访问的秒杀接口地址都是一样的, 会将秒杀地址暴露给某些恶意的攻击者, 会持续的调用秒杀地址接口, 对服务器造成了压力. 隐藏秒杀地址: 用户点击 "立即秒杀" 之后, 并不是直接调用秒杀接口, 而是会请求服务端生成一个随机path的接口方法, 请求会携带goods_id, 服务端接收到请求之后, 利用UUID生成随机的字符串, 会以用户的user_id和goods_id做为key, 将这个随机的path存储在redis中, 并且设置这个path的有效期为60秒, 然后将这个path字符串返回给客户端. 客户端接收到到path之后, 再将path和服务端秒杀接口进行拼接, 去请求服务端的秒杀方法 ![img](https://note.youdao.com/yws/public/resource/e668857a837755960e74ed44180a3120/xmlnote/57577C13AA7D4016BBB28B2F991C33B7/31201) 服务端接收到这个请求之后, 使用 @PathVariable 注解将请求路径中的path解析出来, 然后根据user_id和goods_id去缓存中找到对应的value, 比较两个path是否相等, 如果相等, 则说明请求秒杀接口正确. ![img](https://note.youdao.com/yws/public/resource/e668857a837755960e74ed44180a3120/xmlnote/DC65A1B0D4BF41BB96C8D43E08688A43/31214) **2. 图形验证码** **作用: (1)防止机器程序不断刷新秒杀接口.** **(2)适当分散用户请求, 使用户请求在一瞬间不至于有太高的并发量.** 在 "立即秒杀" 按钮之前, 加一个数学公式的验证码, 就是一个图片, 一个输入框, 在秒杀之前需要先输入验证码. 点击验证码, 会请求服务端生成验证码的接口方法, 相当于可以刷新一次验证码. 使用的是BufferedImage和Graphics类生成验证码, 类似于 1+2+3这样的, 然后将验证码计算得到的结果存储到redis中, 设置验证码的有效期为5分钟, 以user_id和goods_id拼接起来为key, 并且拼接上前缀. 然后用ImageIO将BufferedImage写入response的输出流中, 就是返回给前端. 再次点击验证码时, 就是重新调用后端生成验证码的接口, 相当于刷新验证码. 然后当用户点击 "立即秒杀" 接口时, 要先输入验证码计算得到的结果, 然后传送给服务端, 在生成随机path之前, 先对验证码进行校验, 从缓存中查询出验证码的值, 与用户输入作比较. **3. 接口限流防刷** 就是对某一个接口应该有限制, 限制一个用户在一分钟之内, 最多访问的次数. 例如, 对秒杀接口限制, 5秒钟最多访问5次, 用户在5秒钟之内的点击 "立即秒杀" 的次数超过5次, 则反馈给客户端 "访问太频繁" 的响应. 刚开始的实现是: 利用redis缓存来实现. 先创建一个AccessKey工具类, 当用户第一次访问的时候, 根据访问的uri和user_id拼接称为Key, 再加上AccessKey前缀, 从缓存中查询, 因为是第一次所以为null, 则创建缓存, 初始值为1, 设置缓存为5秒, 此后在5秒钟内该用户每访问一次, 缓存中对应的value就加1, 如果超过5次, 就返回客户端 "访问过于频繁" 的信息. 如果在5秒钟内访问没有超过5次, 则不会返回. 这样实现大体上可以实现限流的功能, 但是和业务逻辑代码完全耦合, 不利于扩展, 并且显得代码非常臃肿. 如果另外的接口要实现5秒钟访问10次, 就很麻烦了. 需要做一个通用的方法, 二次优化: 添加一个拦截器, 在拦截器中拦截请求. 思路: 在需要做限流的接口方法上添加自定义注解, 注解中的值就是多少秒之内, 限制访问多少次. ![img](https://note.youdao.com/yws/public/resource/e668857a837755960e74ed44180a3120/xmlnote/2858465B90A044C2A36A89C78B112333/31316) 类似于上面这样, seconds表示5秒, maxCount表示在5秒之内最多访问5次. 新建注解 @AccessLimit ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface AccessLimit { int seconds(); int maxCount(); boolean needLogin() default true; } ``` 添加一个AccessInterceptor拦截器 ```java @Service public class AccessInterceptor implements HandlerInterceptor { @Autowired HostHolder hostHolder; @Autowired RedisServer redisServer; @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler) throws Exception { if(handler instanceof HandlerMethod){ if(hostHolder.getUser() == null){ render(httpServletResponse,CodeMsg.SESSION_ERROR); return false; } HandlerMethod hm = (HandlerMethod)handler; AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class); if(accessLimit == null){ //AccessLimit为null表示不做任何限流 return true; } //获取AccessLimit注解的信息 String key = httpServletRequest.getRequestURI(); key += "_" + hostHolder.getUser().getId(); int seconds = accessLimit.seconds(); int maxCount = accessLimit.maxCount(); //根据key从缓存中获取当前用户已经访问的次数 Integer count = redisServer.get(AccessKey.withExpire(seconds),key,Integer.class); if(count == null){ //表明还没有访问过 redisServer.set(AccessKey.withExpire(seconds),key,1); }else if(count < 5){ //如果访问次数小于5次,直接增加一次访问次数 redisServer.incr(AccessKey.withExpire(seconds),key); }else{ //访问过于频繁 //将结果要返回给浏览器 render(httpServletResponse,CodeMsg.ACCESS_LIMIT_REACHE); return false; } } return true; } //向浏览器发送JSON格式数据,提示用户访问过于频繁 private void render(HttpServletResponse response,CodeMsg accessLimit) throws IOException { //设置响应数据的编码防止前端出现乱码 response.setContentType("application/json;charset=UTF-8"); OutputStream out = response.getOutputStream(); String str = JSONObject.toJSONString(Result.error(accessLimit)); out.write(str.getBytes("UTF-8")); out.flush(); out.close(); } @Override public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception { } } ``` 这块遇到的问题: 当前端点击次数超过限制时, 并没有在页面上显示出访问频繁的信息, 查看后端返回的JSON数据发现正确, 最后打开浏览器开发工具, 才发现前端接收到的数据出现乱码, 所以需要设置response.setContentType("application/json;charset=UTF-8"); 来防止乱码.