秒杀系统

  • (一)搭建环境
    • 自定义封装Result类
    • 自定义封装CodeMsg类
    • 集成redis和rabbit
    • 封装RedisService类
    • 断言和日志测试
  • (二)实现用户登录和分布式Session
    • 数据表的设计
    • md5工具类
    • 开发登录功能
      • 自定义注解使用场景
      • 全局异常处理器
    • 实现分布式Session
    • 获取cookie中的token
    • token鉴权开发
  • (三)秒杀开发
    • 连表查询小技巧
    • 秒杀功能实现逻辑(重点)
  • (四)秒杀压测
  • (五)页面级优化(加入redis缓存)
  • (六)服务级优化(加入Rabbitmq)
    • SpringBoot集成Rabbitmq
    • 秒杀接口优化思路
      • 库存预加载到Redis中
      • 开始秒杀,预减库存
      • 加入消息队列中(Direct Exchange)
      • 消息发送过程
      • 消息出队处理
      • 秒杀方法
  • (七)图形验证码及恶意防刷
    • 图形验证码
    • 恶意防刷:动态秒杀地址
    • 恶意防刷:接口限流
  • (八)面试题
    • 1. 库存预加载到Redis中是怎么实现的?
      • 1.1 之后主动添加秒杀商品的话,怎么添加?
    • 2. 在Redis中扣减库存的时候,是怎么保证线程安全,防止超卖的?
    • 3. 如果出现Redis缓存雪崩、穿透,怎么解决?
    • 4. 限流防刷是怎么实现的?
    • 5. 对于用户的恶意下单,他知道了你的URL地址,不停的刷,怎么办?
    • 6. 秒杀成功后是怎么同步到数据库中的?
      • 6.1 减库存成功,创建秒杀订单失败了怎么办?
      • 6.2 Spring默认的事务隔离级别
    • 7. RabbitMQ怎么提高消息的高可用?
    • 8. 说说volatile关键字儿
    • 9. TCP和UDP的区别
    • 10. ArrayList

参考慕课网若鱼老师的教程,进行一些总结。

(一)搭建环境

使用springboot进行搭建,包管理工具使用maven。新建一个springboot工程,在pom文件中添加如下依赖

<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>1.3.1</version></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid</artifactId><version>1.0.5</version></dependency><dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId></dependency><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.38</version></dependency><dependency><groupId>commons-codec</groupId><artifactId>commons-codec</artifactId></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>3.6</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency><dependency>  <groupId>org.springframework.boot</groupId>  <artifactId>spring-boot-starter-amqp</artifactId>  </dependency>  <dependency>  <groupId>org.springframework.boot</groupId>  <artifactId>spring-boot-starter-amqp</artifactId>  </dependency></dependencies>

自定义封装Result类

一般而言,后端是返回给前端Json数据,其数据类型常见为code,msg,data。因此自己定义封装好的一个result类,后续的数据都使用该类进行返回给前端。
这里使用了泛型技术:支持传入不同类型的 data
总结一下这个result类的作用:在成功和失败的时候用于结果的返回,其中引入了CodeMsg,自定义状态码和信息。

package com.imooc.miaosha.result;public class Result<T> {private int code;private String msg;private T data;/***  成功时候的调用:返回data数据* */public static  <T> Result<T> success(T data){return new Result<T>(data);}/***  失败时候的调用:返回code和msg(封装了CodeMsg类)* */public static  <T> Result<T> error(CodeMsg codeMsg){return new Result<T>(codeMsg);}private Result(T data) {this.data = data;}private Result(int code, String msg) {this.code = code;this.msg = msg;}// 引入了 CodeMsg,自定义状态码和信息private Result(CodeMsg codeMsg) {if(codeMsg != null) {this.code = codeMsg.getCode();this.msg = codeMsg.getMsg();}}public int getCode() {return code;}public void setCode(int code) {this.code = code;}public String getMsg() {return msg;}public void setMsg(String msg) {this.msg = msg;}public T getData() {return data;}public void setData(T data) {this.data = data;}
}

自定义封装CodeMsg类

提前定义好可能出现的信息,便于传递给result类(不然result类中的一个一个写code和msg,麻烦死了)

package com.imooc.miaosha.result;public class CodeMsg {private int code;private String msg;//通用的错误码public static CodeMsg SUCCESS = new CodeMsg(0, "success");public static CodeMsg SERVER_ERROR = new CodeMsg(500100, "服务端异常");public static CodeMsg BIND_ERROR = new CodeMsg(500101, "参数校验异常:%s");public static CodeMsg REQUEST_ILLEGAL = new CodeMsg(500102, "请求非法");public static CodeMsg ACCESS_LIMIT_REACHED= new CodeMsg(500104, "访问太频繁!");//登录模块 5002XXpublic static CodeMsg SESSION_ERROR = new CodeMsg(500210, "Session不存在或者已经失效");public static CodeMsg PASSWORD_EMPTY = new CodeMsg(500211, "登录密码不能为空");public static CodeMsg MOBILE_EMPTY = new CodeMsg(500212, "手机号不能为空");public static CodeMsg MOBILE_ERROR = new CodeMsg(500213, "手机号格式错误");public static CodeMsg MOBILE_NOT_EXIST = new CodeMsg(500214, "手机号不存在");public static CodeMsg PASSWORD_ERROR = new CodeMsg(500215, "密码错误");//商品模块 5003XX//订单模块 5004XXpublic static CodeMsg ORDER_NOT_EXIST = new CodeMsg(500400, "订单不存在");//秒杀模块 5005XXpublic static CodeMsg MIAO_SHA_OVER = new CodeMsg(500500, "商品已经秒杀完毕");public static CodeMsg REPEATE_MIAOSHA = new CodeMsg(500501, "不能重复秒杀");public static CodeMsg MIAOSHA_FAIL = new CodeMsg(500502, "秒杀失败");private CodeMsg( ) {}private CodeMsg( int code,String msg ) {this.code = code;this.msg = msg;}public int getCode() {return code;}public void setCode(int code) {this.code = code;}public String getMsg() {return msg;}public void setMsg(String msg) {this.msg = msg;}public CodeMsg fillArgs(Object... args) {int code = this.code;// 用于格式化填充后的参数:this.msg中的参数会被args填充String message = String.format(this.msg, args);return new CodeMsg(code, message);}@Overridepublic String toString() {return "CodeMsg [code=" + code + ", msg=" + msg + "]";}}

集成redis和rabbit

在云服务器上使用docker安装redis和rabbit,注意云服务器上开通redis和rabbitmq(包括管理页面端)的端口,在redis和rabbit中分别去修改配置文件,然后使用rdm连接redis测试,登录rabbitmq管理页面端测试。
贴出application.properties代码

#thymeleaf 配置信息,默认走tempaltes下的html
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache=false
spring.thymeleaf.content-type=text/html
spring.thymeleaf.enabled=true
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.mode=HTML5
# mybatis 配置信息,定义mapperLocations位置,防止包结果不同找不到xml
mybatis.type-aliases-package=com.imooc.miaosha.domain
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.configuration.default-fetch-size=100
mybatis.configuration.default-statement-timeout=3000
mybatis.mapperLocations = classpath:com/imooc/miaosha/dao/*.xml
# druid 使用德鲁伊数据库连接池,初始使用本地数据库连接
spring.datasource.url=jdbc:mysql://localhost:3306/miaosha?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&useSSL=false
spring.datasource.username=root
spring.datasource.password=javan1996
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.filters=stat
spring.datasource.maxActive=1000
spring.datasource.initialSize=100
spring.datasource.maxWait=60000
spring.datasource.minIdle=500
spring.datasource.timeBetweenEvictionRunsMillis=60000
spring.datasource.minEvictableIdleTimeMillis=300000
spring.datasource.validationQuery=select 'x'
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
spring.datasource.poolPreparedStatements=true
spring.datasource.maxOpenPreparedStatements=20
#redis
redis.host=114.132.248.249
redis.port=6379
redis.timeout=10
#redis.password=123456
redis.poolMaxTotal=1000
redis.poolMaxIdle=500
redis.poolMaxWait=500
#static 配置静态缓存,可以将页面缓存到浏览器中
spring.resources.add-mappings=true
spring.resources.cache-period= 3600
spring.resources.chain.cache=true
spring.resources.chain.enabled=true
spring.resources.chain.gzipped=true
spring.resources.chain.html-application-cache=true
spring.resources.static-locations=classpath:/static/
#rabbitmq
spring.rabbitmq.host=114.132.248.249
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
spring.rabbitmq.virtual-host=/
#\u6D88\u8D39\u8005\u6570\u91CF
spring.rabbitmq.listener.simple.concurrency= 10
spring.rabbitmq.listener.simple.max-concurrency= 10
#\u6D88\u8D39\u8005\u6BCF\u6B21\u4ECE\u961F\u5217\u83B7\u53D6\u7684\u6D88\u606F\u6570\u91CF
spring.rabbitmq.listener.simple.prefetch= 1
#\u6D88\u8D39\u8005\u81EA\u52A8\u542F\u52A8
spring.rabbitmq.listener.simple.auto-startup=true
#\u6D88\u8D39\u5931\u8D25\uFF0C\u81EA\u52A8\u91CD\u65B0\u5165\u961F
spring.rabbitmq.listener.simple.default-requeue-rejected= true
#\u542F\u7528\u53D1\u9001\u91CD\u8BD5
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.0

springboot使用jedis连接redis,采用连接池思想,所以要创建JedisPool。
先写RedisConfig类,读取properties文件的信息(类属性和properties文件信息吻合),要加注解
@ConfigurationProperties(prefix=“redis”),表示读取前缀配置信息

package com.imooc.miaosha.redis;import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;@Component
@ConfigurationProperties(prefix="redis")
public class RedisConfig {private String host;private int port;private int timeout;//秒private String password;private int poolMaxTotal;private int poolMaxIdle;private int poolMaxWait;//秒public String getHost() {return host;}public void setHost(String host) {this.host = host;}public int getPort() {return port;}public void setPort(int port) {this.port = port;}public int getTimeout() {return timeout;}public void setTimeout(int timeout) {this.timeout = timeout;}public String getPassword() {return password;}public void setPassword(String password) {this.password = password;}public int getPoolMaxTotal() {return poolMaxTotal;}public void setPoolMaxTotal(int poolMaxTotal) {this.poolMaxTotal = poolMaxTotal;}public int getPoolMaxIdle() {return poolMaxIdle;}public void setPoolMaxIdle(int poolMaxIdle) {this.poolMaxIdle = poolMaxIdle;}public int getPoolMaxWait() {return poolMaxWait;}public void setPoolMaxWait(int poolMaxWait) {this.poolMaxWait = poolMaxWait;}
}

然后创建JedisPool,使用工厂模式去生成jedisPool,因此建立一个工厂类JedisPoolFactory

package com.imooc.miaosha.redis;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Service;import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;@Service
public class RedisPoolFactory {@AutowiredRedisConfig redisConfig;@Beanpublic JedisPool JedisPoolFactory() {JedisPoolConfig poolConfig = new JedisPoolConfig();poolConfig.setMaxIdle(redisConfig.getPoolMaxIdle());poolConfig.setMaxTotal(redisConfig.getPoolMaxTotal());poolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait() * 1000);JedisPool jp = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),redisConfig.getTimeout()*1000, redisConfig.getPassword(), 0);return jp;}}

到此为止redis的基本配置完成了
redis是key-value数据库,key很容易冲突,也就是说我们需要取定义key的前缀,防止key冲突。这个前缀修饰采用的模板方法模式的应用。

先定义接口KeyPrefix:获得失效时间和获取前缀

package com.imooc.miaosha.redis;public interface KeyPrefix {public int expireSeconds();public String getPrefix();}

再定义一个抽象类BasePrefix ,去实现接口。这个抽象类就是一个模板了,后续的实现类都是实现该模板。

package com.imooc.miaosha.redis;public abstract class BasePrefix implements KeyPrefix{// 过期时间 0-永不过期  其它-过期的秒数private int expireSeconds;// 传入的前缀名,真实的redis前缀应该是类名+传入的前缀名private String prefix;public BasePrefix(String prefix) {//0代表永不过期this(0, prefix);}public BasePrefix( int expireSeconds, String prefix) {this.expireSeconds = expireSeconds;this.prefix = prefix;}public int expireSeconds() {//默认0代表永不过期return expireSeconds;}// 获取真实的前缀名public String getPrefix() {// 获取类名String className = getClass().getSimpleName();// 类名拼接传入的前缀名 == redis真实前缀return className+":" + prefix;}}

最后就是定义具体业务的实现类了,这里举个例子,定义一个用户前缀名实现类UserKey

package com.imooc.miaosha.redis;public class UserKey extends BasePrefix{// 调用父类构造函数,默认是永不过期private UserKey(String prefix) {super(prefix);}// 定义id的前缀名, 当前类名:id == user  其实就是  UserKey:idpublic static UserKey getById = new UserKey("id");// 定义name的前缀名, 当前类名类名:id    其实就是  UserKey:namepublic static UserKey getByName = new UserKey("name");
}

接下来就是去定义redis服务类,这个服务类其实就是去操作redis数据,核心就是:获取redis对象,设置redis对象,增加key,减少key,判断key是否存在…
其中获取redis对象,设置redis对象使用了stringtobean技术和beantostring技术,这两个重点关注!(用到了json和java对象互相转换的技术)

封装RedisService类

package com.imooc.miaosha.redis;import java.util.ArrayList;
import java.util.List;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;import com.alibaba.fastjson.JSON;import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.ScanParams;
import redis.clients.jedis.ScanResult;@Service
public class RedisService {@AutowiredJedisPool jedisPool;/*** 获取当个对象,Class<T> clazz 表示的是value的类型* */public <T> T get(KeyPrefix prefix, String key,  Class<T> clazz) {Jedis jedis = null;try {jedis =  jedisPool.getResource();//生成真正的keyString realKey  = prefix.getPrefix() + key;String  str = jedis.get(realKey);T t =  stringToBean(str, clazz);return t;}finally {returnToPool(jedis);}}/*** 设置对象:set方法,我们需要将value值转换为String类型,让Redis能够识别* 不然那么多类型,redis无法识别啊,只能使用string过渡一下* */public <T> boolean set(KeyPrefix prefix, String key,  T value) {Jedis jedis = null;try {jedis =  jedisPool.getResource();String str = beanToString(value);if(str == null || str.length() <= 0) {return false;}//生成真正的keyString realKey  = prefix.getPrefix() + key;int seconds =  prefix.expireSeconds();if(seconds <= 0) {// 不设置过期时间jedis.set(realKey, str);}else {// 设置过期时间jedis.setex(realKey, seconds, str);}return true;}finally {returnToPool(jedis);}}/*** 判断key是否存在* */public <T> boolean exists(KeyPrefix prefix, String key) {Jedis jedis = null;try {jedis =  jedisPool.getResource();//生成真正的keyString realKey  = prefix.getPrefix() + key;return  jedis.exists(realKey);}finally {returnToPool(jedis);}}/*** 删除* */public boolean delete(KeyPrefix prefix, String key) {Jedis jedis = null;try {jedis =  jedisPool.getResource();//生成真正的keyString realKey  = prefix.getPrefix() + key;long ret =  jedis.del(realKey);return ret > 0;}finally {returnToPool(jedis);}}/*** 增加值* */public <T> Long incr(KeyPrefix prefix, String key) {Jedis jedis = null;try {jedis =  jedisPool.getResource();//生成真正的keyString realKey  = prefix.getPrefix() + key;return  jedis.incr(realKey);}finally {returnToPool(jedis);}}/*** 减少值* */public <T> Long decr(KeyPrefix prefix, String key) {Jedis jedis = null;try {jedis =  jedisPool.getResource();//生成真正的keyString realKey  = prefix.getPrefix() + key;return  jedis.decr(realKey);}finally {returnToPool(jedis);}}public boolean delete(KeyPrefix prefix) {if(prefix == null) {return false;}List<String> keys = scanKeys(prefix.getPrefix());if(keys==null || keys.size() <= 0) {return true;}Jedis jedis = null;try {jedis = jedisPool.getResource();jedis.del(keys.toArray(new String[0]));return true;} catch (final Exception e) {e.printStackTrace();return false;} finally {if(jedis != null) {jedis.close();}}}public List<String> scanKeys(String key) {Jedis jedis = null;try {jedis = jedisPool.getResource();List<String> keys = new ArrayList<String>();String cursor = "0";ScanParams sp = new ScanParams();sp.match("*"+key+"*");sp.count(100);do{ScanResult<String> ret = jedis.scan(cursor, sp);List<String> result = ret.getResult();if(result!=null && result.size() > 0){keys.addAll(result);}//再处理cursorcursor = ret.getStringCursor();}while(!cursor.equals("0"));return keys;} finally {if (jedis != null) {jedis.close();}}}public static <T> String beanToString(T value) {if(value == null) {return null;}Class<?> clazz = value.getClass();if(clazz == int.class || clazz == Integer.class) {return ""+value;}else if(clazz == String.class) {return (String)value;}else if(clazz == long.class || clazz == Long.class) {return ""+value;}else {return JSON.toJSONString(value);}}@SuppressWarnings("unchecked")public static <T> T stringToBean(String str, Class<T> clazz) {if(str == null || str.length() <= 0 || clazz == null) {return null;}if(clazz == int.class || clazz == Integer.class) {return (T)Integer.valueOf(str);}else if(clazz == String.class) {return (T)str;}else if(clazz == long.class || clazz == Long.class) {return  (T)Long.valueOf(str);}else {return JSON.toJavaObject(JSON.parseObject(str), clazz);}}private void returnToPool(Jedis jedis) {if(jedis != null) {jedis.close();}}}

接下来自己写一个测试类去调用redisservice的方法,然后在rdm查看相关的信息,验证一下!!!
这里我使用了springboot的测试类,同时使用logger日志和断言技术

断言和日志测试

package com.imooc.miaosha.redis;import com.sun.scenario.effect.impl.sw.sse.SSEBlend_SRC_OUTPeer;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisServiceTest {@Autowiredprivate RedisService redisService;private static final Logger logger = LoggerFactory.getLogger(RedisServiceTest.class);@Beforepublic void init() {System.out.println("开始测试-----------------");}@Testpublic void testSetkey(){Assert.assertSame(true,redisService.set(UserKey.getById,""+1,100));System.out.println("111111111");logger.info("测试结束");}@Afterpublic void after() {System.out.println("测试结束-----------------");}}



测试成功!
rabbitmq涉及到后期的优化,后续再记录!

(二)实现用户登录和分布式Session

前面环境搭好了,现在要开始设计数据库了!!!
我们要做的业务是秒杀业务,但是在实际中商品的售卖活动不当当有秒杀,还有有节假日的优惠活动等等,因此要把秒杀封装解耦成单独的业务。

数据表的设计

常见的电商交易必备的数据表有:商品表、订单表、用户表,在上面的基础上增加秒杀业务,也就是增加了秒杀商品表,秒杀订单表,秒杀用户表
接下来就是思考数据库主键的选择了,使用mysql的自增id?UUID?还是雪花算法?
在实际的业务场景中要使用不同的数据库主键,贴出一个链接,对应各自的适用场景,总结一句话就是:**单实例或者单节点组使用子增id,小规模的分布式场景下使用uuid,大规模的分布式场景下使用雪花算法构造的全局自增id作为主键。**本次秒杀系统设计自然就是选择了mysql自增id了
数据库主键的对比
在实际的数据库设计过程中,一般先思考uml用例图和e-r图,这样写字段就毫无压力。

商品表

秒杀商品表

订单表

秒杀订单表


秒杀用户表
所有的数据库表请写上注释!!!
注意:秒杀用户表的id是使用手机号码的,因此不能使用自增id了。表的设计过程中储存图像一般是用相对路径(储存前端的static下)或者七牛云的云储存路径(完整的http链接)。用户表的密码一般是md5加密过的。比较常见的是单次md5加密,但是一旦后端代码和数据库落入黑客手中(前端可以看到js代码),就可以根据md5反查表得到密码,所以这里使用了两次md5加密。
第一次md5:传输安全加密(http明文传递),(Password1 = MD5(inputPassword,固定的salt值),salt为字符串)
第二次md5:数据库安全加密,(Password2 = MD5(Password1,随机的salt值))
字符集采用的是utf8mb4

md5工具类

pom导入md的依赖包和常见的工具类包

        <dependency><groupId>commons-codec</groupId><artifactId>commons-codec</artifactId></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>3.6</version></dependency>
package com.imooc.miaosha.util;import org.apache.commons.codec.digest.DigestUtils;public class MD5Util {//静态的salt,用于第一次MD5public static String md5(String src) {//调用DigestUtils,实现md5处理return DigestUtils.md5Hex(src);}private static final String salt = "1a2b3c4d";/*** 第一次md5* @param inputPass* @return*/public static String inputPassToFormPass(String inputPass) {String str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);System.out.println(str);return md5(str);}/*** 第二次md5* @param formPass* @param salt* @return*/public static String formPassToDBPass(String formPass, String salt) {String str = ""+salt.charAt(0)+salt.charAt(2) + formPass +salt.charAt(5) + salt.charAt(4);return md5(str);}/*** 整合两次md5加密* @param inputPass* @param saltDB* @return*/public static String inputPassToDbPass(String inputPass, String saltDB) {String formPass = inputPassToFormPass(inputPass);String dbPass = formPassToDBPass(formPass, saltDB);return dbPass;}// 测试main函数public static void main(String[] args) {System.out.println(inputPassToFormPass("123456"));//d3b1294a61a07da9b49b6e22b2cbd7f9
//      System.out.println(formPassToDBPass(inputPassToFormPass("123456"), "1a2b3c4d"));
//      System.out.println(inputPassToDbPass("123456", "1a2b3c4d"));//b7797cce01b4b131b433b6acf4add449}}

接下来就是常见的三层mvc架构了,图省事的话,可以使用easycode插件右击数据库表,生成三层架构。
这里要注意,domain/entity对应的是数据库的字段类型,实际上前后端数据交互有可能只需要domain/entity的部分字段,甚至要扩展字段,这时候怎么办?新增一个vo层(view object)用户视图的数据交互,前端传数据给后端,后端返回数据给前端(封装到result类中的data中)

开发登录功能

首先写一个LoginVo(使用手机号和密码进行登录),这里使用了validation包中的注解@NotNull等等

package com.imooc.miaosha.vo;import javax.validation.constraints.NotNull;import org.hibernate.validator.constraints.Length;import com.imooc.miaosha.validator.IsMobile;public class LoginVo {@NotNull@IsMobileprivate String mobile;@NotNull@Length(min=32)private String password;public String getMobile() {return mobile;}public void setMobile(String mobile) {this.mobile = mobile;}public String getPassword() {return password;}public void setPassword(String password) {this.password = password;}@Overridepublic String toString() {return "LoginVo [mobile=" + mobile + ", password=" + password + "]";}
}

上面还使用了一个自定义注解:@IsMobile,怎么去自定义注解呢???
首先建一个包:annoation/valiation(名字合理即可),创建一个注解类,一般包括 require、message、group、payload字段信息(上面的注解去抄一下@Notnull的注解)

自定义注解使用场景

package com.imooc.miaosha.validator;import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;import javax.validation.Constraint;
import javax.validation.Payload;@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class })
public @interface  IsMobile {boolean required() default true;String message() default "手机号码格式错误";Class<?>[] groups() default { };Class<? extends Payload>[] payload() default { };
}

注意:@Constraint(validatedBy = {IsMobileValidator.class })限制了该注解的实现方法
接下来就是去实现这个手机号码验证器类了IsMobileValidator

package com.imooc.miaosha.validator;
import  javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;import org.apache.commons.lang3.StringUtils;import com.imooc.miaosha.util.ValidatorUtil;public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {private boolean required = false;// 初始化方法,它调用的是我们自定义注解中写的required()方法,默认需要有值public void initialize(IsMobile constraintAnnotation) {required = constraintAnnotation.required();}//isValid,则对逻辑进行验证,true验证通过,false验证失败public boolean isValid(String value, ConstraintValidatorContext context) {if(required) {// 调用自己写的工具类return ValidatorUtil.isMobile(value);}else {if(StringUtils.isEmpty(value)) {return true;}else {return ValidatorUtil.isMobile(value);}}}}

期间再写一个判断手机号码的工具类ValidatorUtil (手机号码是11位,这里使用了正则表达式)
唉!~上面的操作为了造一个@Ismobile注解轮子,花费了这么多功夫,简直有点麻烦!!!

但是一般而言注解是用在两个场景:
自定义注解+拦截器 实现登录校验 和 自定义注解+AOP 实现日志打印
上面的操作注解属实有点冗余,不是实际开发的方向…(我直接使用自定义注解+拦截器 实现登录校验,这样子不香嘛????何必再单独做一个@IsMobile的注解小轮子呢)
注解的使用场景

package com.imooc.miaosha.util;import java.util.regex.Matcher;
import java.util.regex.Pattern;import org.apache.commons.lang3.StringUtils;public class ValidatorUtil {private static final Pattern mobile_pattern = Pattern.compile("1\\d{10}");public static boolean isMobile(String src) {if(StringUtils.isEmpty(src)) {return false;}Matcher m = mobile_pattern.matcher(src);return m.matches();}// public static void main(String[] args) {//          System.out.println(isMobile("18912341234"));
//          System.out.println(isMobile("1891234123"));
//  }
}

然后在controller层的doLogin方法加上JSR验证,@Valid注解即可生效

package com.imooc.miaosha.controller;import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;import com.imooc.miaosha.redis.RedisService;
import com.imooc.miaosha.result.Result;
import com.imooc.miaosha.service.MiaoshaUserService;
import com.imooc.miaosha.vo.LoginVo;@Controller
@RequestMapping("/login")
public class LoginController {private static Logger log = LoggerFactory.getLogger(LoginController.class);@AutowiredMiaoshaUserService userService;@AutowiredRedisService redisService;@RequestMapping("/to_login")public String toLogin() {return "login";}@RequestMapping("/do_login")@ResponseBody// 记得加上@Valid,这样validation包才会生效public Result<String> doLogin(HttpServletResponse response, @Valid LoginVo loginVo) {log.info(loginVo.toString());//登录String token = userService.login(response, loginVo);return Result.success(token);}
}

全局异常处理器

思考如下代码:

    public CodeMsg login(LoginVo loginVo){if(loginVo == null){return CodeMsg.SERVER_ERROR;}String mobile = loginVo.getMobile();String password = loginVo.getPassword();//判断手机号是否存在MiaoShaUser user = getById(Long.parseLong(mobile));if(user == null){return CodeMsg.MOBILE_NOT_EXIST;}//验证密码String DBPass = user.getPassword();//这里对前端来的密码第二次MD5处理String formPassToDBPass = MD5Util.formPassToDBPass(password, user.getSalt());if(!formPassToDBPass.equals(DBPass)){return CodeMsg.PASSWORD_ERROR;}return CodeMsg.SUCCESS;}

它的返回值是CodeMsg,而在业务中,方法对应的返回值应该是确切的,我们登陆,返回应该为 true 或 false,所以,我们要对这里进行优化

    public boolean login(LoginVo loginVo){if(loginVo == null){throw new GlobalException(CodeMsg.SERVER_ERROR);}String mobile = loginVo.getMobile();String password = loginVo.getPassword();//判断手机号是否存在MiaoShaUser user = getById(Long.parseLong(mobile));if(user == null){throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);}//验证密码String DBPass = user.getPassword();//这里对前端来的密码第二次MD5处理String formPassToDBPass = MD5Util.formPassToDBPass(password, user.getSalt());if(!formPassToDBPass.equals(DBPass)){throw new GlobalException(CodeMsg.PASSWORD_ERROR);}return true;}

我们可以发现,对应的参数验证,并没有返回值,而是直接抛出异常,而且我们也将返回值进行了修改,执行到方法的最后,能够返回ture
新建一个exception包,定义全局异常类GlobalException (实际上就是继承RuntimeException,封装了返回信息codemsg)

package com.imooc.miaosha.exception;import com.imooc.miaosha.result.CodeMsg;public class GlobalException extends RuntimeException{private static final long serialVersionUID = 1L;private CodeMsg cm;public GlobalException(CodeMsg cm) {// RuntimeException类的构造函数,抛出异常信息super(cm.toString());this.cm = cm;}public CodeMsg getCm() {return cm;}}

定义全局异常处理器GlobalExceptionHandler

package com.imooc.miaosha.exception;import java.util.List;import javax.servlet.http.HttpServletRequest;import org.springframework.validation.BindException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;import com.imooc.miaosha.result.CodeMsg;
import com.imooc.miaosha.result.Result;/*** 只能处理 controller 层抛出的异常,对例如 Interceptor(拦截器)层的异常、定时任务中的异常、异步方法中的异常,不会进行处理。** 以上就是用 @ControllerAdvice + @ExceptionHand 实现 SpringBoot 中捕获 controller 层全局异常并处理的方法。* 像工具类中或者其他类中的异常,拦截异常可以使用aop操作。**/
//定义该类为全局异常处理类。
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {//定义该方法为异常处理方法。value 的值为需要处理的异常类的 class 文件。@ExceptionHandler(value=Exception.class)public Result<String> exceptionHandler(HttpServletRequest request, Exception e){e.printStackTrace();if(e instanceof GlobalException) {// 属于全局异常GlobalException ex = (GlobalException)e;return Result.error(ex.getCm());}else if(e instanceof BindException) {// 属于绑定异常BindException ex = (BindException)e;List<ObjectError> errors = ex.getAllErrors();ObjectError error = errors.get(0);String msg = error.getDefaultMessage();// 按照格式输出绑定异常的信息return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));}else {// 否则统一输出服务端异常return Result.error(CodeMsg.SERVER_ERROR);}}
}

为什么要全局异常处理以及使用场景有哪些?

实现分布式Session


作用:用Redis存储Session值,在Redis中通过token值来获取用户信息
每次登陆,将Session的过期时间进行修正:
Session值固定过期时间为30min,要在每次登陆的时候,以当前时间继续顺延30分钟
我们的解决方法就是,每次登陆时,重新再添加一次Cookie,则能够完成时间延长

    private void addCookie(HttpServletResponse response, MiaoShaUser user, String token) {//首次登陆的时候,需要将Cookie存入Redis// MiaoShaUserKey.token 是自定义的redis key前缀redisService.set(MiaoShaUserKey.token,token,user);// public static final String COOKI_NAME_TOKEN = "token";Cookie cookie = new Cookie(COOKIE_NAME_TOKEN, token);// 每次都更新过期时间,过期时间为2天,3600*24 * 2cookie.setMaxAge(MiaoShaUserKey.token.expireSeconds());//设置为根目录,则可以在整个应用范围内使用cookiecookie.setPath("/");// 增加cookieresponse.addCookie(cookie);}

上面的流程图中解释了,每次客户端都携带cookie访问服务端,服务端提取cookie中的token值验证用户信息。
那么获取Cookie值的两种方式:

@RequestMapping("test")
public String test(ModelMap mm, HttpServletResponse response) {// 在response中存入Cookieresponse.addCookie(new Cookie("name", "value"));return "test";
}@RequestMapping("/getCookie")
public String getCookie(@CookieValue("name")String name, HttpServletRequest request) {// 方式一: 通过request获取Cookie数组,然后循环Cookie[] cookies = request.getCookies();for (Cookie item : cookies) {System.out.println(item.getName()+":"+item.getValue());}// 方式二: 直接使用@CookieValue获取或者@RequestParamSystem.out.println(name);return null;
}

在本项目中:

    @RequestMapping("/to_list")public String toList(Model model,@CookieValue(value = MiaoShaUserService.COOKIE_NAME_TOKEN,required = false) String cookieToken,@RequestParam(value = MiaoShaUserService.COOKIE_NAME_TOKEN,required = false) String paramToken,){if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)){return "login";}String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;// miaoShaUserService.getByToken 在redis中根据token取出user信息,同时延长过期时间MiaoShaUser user = miaoShaUserService.getByToken(response,token);model.addAttribute("user",user);return "goods_list";}

开发中很显然是使用注解@CookieValue(key)和@RequestParam(key)获取[一个是从cookie获取,一个是从request获取]
优化点一:使用WebMvcConfigurer中addArgumentResolvers方法(参数解析)
按照一般常理来是,上面的操作就可以了,每次都使用注解获取,但是有没有发现一个问题,这个注解很冗余啊,每次都要加注解,然后判断有没有token,属实麻烦,有没有可能在进入controller层前的拦截器阶段就给我自动捕获这个token???
本项目的代码:
首先建立一个config包,新建一个WebConfig类

获取cookie中的token

package com.imooc.miaosha.config;import java.util.List;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;import com.imooc.miaosha.access.AccessInterceptor;@Configuration
//WebConfig继承了WebMvcConfigurerAdapter,会在controller层前进行处理重写的业务方法
public class WebConfig  extends WebMvcConfigurerAdapter{@Autowired// 自定义的参数解析类UserArgumentResolver userArgumentResolver;@Autowired// 自定义的拦截器类类AccessInterceptor accessInterceptor;@Override// 参数解析public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {argumentResolvers.add(userArgumentResolver);}@Override// 拦截器public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(accessInterceptor);}}

然后自定义一个参数解析类UserArgumentResolver

package com.imooc.miaosha.config;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;import com.imooc.miaosha.access.UserContext;
import com.imooc.miaosha.domain.MiaoshaUser;
import com.imooc.miaosha.service.MiaoshaUserService;@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {@AutowiredMiaoshaUserService userService;// 判断该请求是否需要处理public boolean supportsParameter(MethodParameter parameter) {Class<?> clazz = parameter.getParameterType();return clazz==MiaoshaUser.class;}// 需要处理的话在这里进行操作public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {// 这里的UserContext.getUser()是使用threadlocal捕获当前线程的用户return UserContext.getUser();}}

在该类中保留参数解析后的成果,使用了ThreadLocal技术,UserContext.getUser()

package com.imooc.miaosha.access;import com.imooc.miaosha.domain.MiaoshaUser;public class UserContext {private static ThreadLocal<MiaoshaUser> userHolder = new ThreadLocal<MiaoshaUser>();public static void setUser(MiaoshaUser user) {userHolder.set(user);}public static MiaoshaUser getUser() {return userHolder.get();}}
最后呢,直接在controller写下面代码就可以了,不用再用注解去获取cookie,判断token的user存在与否```java@RequestMapping("/to_list")public String toList(Model model,MiaoShaUser user){model.addAttribute("user",user);return "goods_list";}

参考链接如下:
WebMvcConfigurer中addArgumentResolvers方法的使用

token鉴权开发

思考:上面的操作其实就是在做登录鉴权,使用的技术是token+threadlocal+redis技术,那么有必要使用参数解析这个方法嘛?直接使用拦截器机制不好嘛?
这里就引出了前后端分离的操作了,与jwt不同的是我们可以自定义token过期时间!
token+threadlocal+redis
定义拦截器AccessInterceptor

package com.imooc.miaosha.access;import java.io.OutputStream;import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;import com.alibaba.fastjson.JSON;
import com.imooc.miaosha.domain.MiaoshaUser;
import com.imooc.miaosha.redis.AccessKey;
import com.imooc.miaosha.redis.RedisService;
import com.imooc.miaosha.result.CodeMsg;
import com.imooc.miaosha.result.Result;
import com.imooc.miaosha.service.MiaoshaUserService;@Service
public class AccessInterceptor  extends HandlerInterceptorAdapter{@AutowiredMiaoshaUserService userService;@AutowiredRedisService redisService;@Override// 拦截器前处理public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {if(handler instanceof HandlerMethod) {MiaoshaUser user = getUser(request, response);UserContext.setUser(user);HandlerMethod hm = (HandlerMethod)handler;// 这里做了接口防刷的策略AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);if(accessLimit == null) {return true;}int seconds = accessLimit.seconds();int maxCount = accessLimit.maxCount();boolean needLogin = accessLimit.needLogin();String key = request.getRequestURI();if(needLogin) {if(user == null) {render(response, CodeMsg.SESSION_ERROR);return false;}key += "_" + user.getId();}else {//do nothing}AccessKey ak = AccessKey.withExpire(seconds);Integer count = redisService.get(ak, key, Integer.class);if(count  == null) {redisService.set(ak, key, 1);}else if(count < maxCount) {redisService.incr(ak, key);}else {render(response, CodeMsg.ACCESS_LIMIT_REACHED);return false;}}return true;}private void render(HttpServletResponse response, CodeMsg cm)throws Exception {response.setContentType("application/json;charset=UTF-8");OutputStream out = response.getOutputStream();String str  = JSON.toJSONString(Result.error(cm));out.write(str.getBytes("UTF-8"));out.flush();out.close();}private MiaoshaUser getUser(HttpServletRequest request, HttpServletResponse response) {String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {return null;}String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;return userService.getByToken(response, token);}private String getCookieValue(HttpServletRequest request, String cookiName) {Cookie[]  cookies = request.getCookies();if(cookies == null || cookies.length <= 0){return null;}for(Cookie cookie : cookies) {if(cookie.getName().equals(cookiName)) {return cookie.getValue();}}return null;}}

然后在在webconfig类中注册该拦截器即可,在前后端分离过程中还要放行相关的静态资源。
现在主流的前后端分离token鉴权方式还是jwt+threadlocal+redis,这块代码知识可以百度得到

@Override// 拦截器public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(accessInterceptor);}

(三)秒杀开发

连表查询小技巧

商品表和秒杀商品表是两个互相独立的表,其中的关联为goods_id,但是我要返回的对象,既想要商品表中的字段,又想要秒杀商品表中的字段,然后返回给前端,那该怎么办???很简单秒杀商品表继承一下商品表的字段,然后加入我们字节想要的字段即可!!!有点优秀!

@Data
public class GoodsVo extends Goods {// 返回给前端的vo层,除了商品表原有字段后,再增加了自己想要的四个字段!// 这四个字段肯定是前端所需要的private Double miaoshaPrice;private Integer stockCount;private Date startDate;private Date endDate;
}

接下来就是写一个GoodsDao,去crud,这里需要两个方法:查询所有商品(使用左连表查询语句)和根据商品id获取商品所有信息

public interface GoodsDao {/*** 查询秒杀商品列表* @return*/public List<GoodsVo> listGoodsVo();/*** 根据商品id获取商品所有信息* @param goodsId* @return*/GoodsVo getGoodsVoByGoodsId(@Param("goodsId") long goodsId);
}

查询sql语句,使用左连表查询

 <select id="listGoodsVo" resultType="com.javan.seckill.vo.GoodsVo">select g.*,mg.stock_count,mg.start_date,mg.end_date,mg.miaosha_pricefrom miaosha_goods mgleft join goods gon mg.goods_id = g.id</select><select id="getGoodsVoByGoodsId" resultType="com.javan.seckill.vo.GoodsVo">select g.*,mg.stock_count,mg.start_date,mg.end_date,mg.miaosha_pricefrom miaosha_goods mgleft join goods gon mg.goods_id = g.idwhere g.id = #{goodsId}</select>

controller层返回数据

    @RequestMapping("to_detail/{goodsId}")public String toDetail(Model model, MiaoShaUser user, @PathVariable("goodsId") long goodsId){...
}

@RequestMapping指定的映射URL,其中有用{}括起来的参数,在方法的形参处,用@PathVariable注解对其进行获取
实际上,在后端开发中不应该返回String类型(这里是thymeleaf开发),而是返回result封装好的类

秒杀功能实现逻辑(重点)


这里要注意就是减少库存和创建订单,这两个是一个事务,要具备原子性,所以要用注解@Transactional在,同时还要考虑的是减少库存在高并发条件下如何防止超卖。
如果是同一个用户发送了两次秒杀请求,这个请求是同步的,很巧妙的避开了秒杀是否成功这个业务,所以最后生成的2条订单,2条秒杀订单。如何避免超买???
解决办法:我们再秒杀订单表中,将userId和goodsId创建 唯一索引

但凡有两条一样的数据,整体的业务就会回滚,保证了一个人一条秒杀订单
秒杀场景下超卖问题解决方案
在本项目中前期使用了数据库悲观锁之排他锁(sql语句加入 where count > 0 )本质是update造成的行级锁,后期使用了redis+rabbitmq(缓存方案,先在缓存中完成计数,也就是预减库存,然后再通过消息队列异步地入库)redis由于其高速+单进程模型,省掉了很多并发的问题,所以可以被选来进行高速秒杀的工作。

(四)秒杀压测

分为windows压测和linux压测。windows本地压测比较简单,关注QPS即可,重点学习下linux下的压测方法
首先在linux下下载好压测工具jmeter(linux版本)和redis压测工具(redis-benchmark)

#100个并发连接,100000个请求
redis-benchmark -h 127.0.0.1 -p 6379 -c 100 -n 100000#存取大小为100字节的数据包
redis-benchmark -h 127.0.0.1 -p 6379 -q -d 100#测试set和lpush命令的QPS,其中-q为简化输出
redis-benchmark -t set,lpush -q -n 1000000#测试单条命令的QPS
redis-benchmark -n 100000 -q script load "redis.call('set','foo','bar')"

在Windows目录下写好jmx文件
命令行:sh jmeter.sh -n -t xxx.jmx -l result.jtl
再将result.jtl导入到windows 下的jmeter中查看QPS

(五)页面级优化(加入redis缓存)

这一章节主要讲解优化思路:页面缓存、url缓存、页面静态化(就是前后端分离了)、对象缓存(将用户的信息放入到redis中,弊端:每次修改用户信息的时候还要更新缓存)。在真实的开发中都是前后端分离的时代了,所以现在这个了解即可!

页面缓存
URL缓存
其他方式:CDN优化+静态资源的压缩
页面静态化:由于没有用到Vue,所以这里使用原生的ajax请求取获取后端数据,前端使用jquery操作dom的方式渲染html,这样页面就可以直接缓存到客户端了,不需要与服务器交互就能访问页面(数据需要和服务器交互)
后端代码:

    @RequestMapping(value = "/detail/{goodsId}")@ResponseBodypublic Result<GoodsDetailVo> toDetail(HttpServletRequest request, HttpServletResponse response, Model model, MiaoShaUser user, @PathVariable("goodsId") long goodsId){GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);//秒杀开始、结束时间,当前时间long startDate = goodsVo.getStartDate().getTime();long endDate = goodsVo.getEndDate().getTime();long now = System.currentTimeMillis();//秒杀状态,0为没开始,1为正在进行,2为秒杀已经结束int miaoshaStatus = 0;//距离秒杀剩余的时间int remainSeconds = 0;if(now < startDate){//秒杀没开始,进行倒计时remainSeconds = (int) (startDate - now) / 1000;}else if(now > endDate){//秒杀已经结束miaoshaStatus = 2;remainSeconds = -1;}else {//秒杀进行时remainSeconds = 0;miaoshaStatus = 1;}GoodsDetailVo goodsDetailVo = new GoodsDetailVo();goodsDetailVo.setGoods(goodsVo);goodsDetailVo.setUser(user);goodsDetailVo.setMiaoshaStatus(miaoshaStatus);goodsDetailVo.setRemainSeconds(remainSeconds);return Result.success(goodsDetailVo);}

vo层:

@Data
public class GoodsDetailVo {private long miaoshaStatus;private long remainSeconds;private GoodsVo goods;private MiaoShaUser user;
}

对应前端:

我们从商品列表页面跳转到商品详情页,修改为如下

注意其中/goods_detail.htm,它是放在static目录下的静态资源,为了防止视图解析器的跳转,将html写为htm,其中goodsId是给前端页面的隐藏输入框传递参数

在application.properties中配置

# static
spring.resources.add-mappings=true
spring.resources.cache.period= 3600 #缓存时间
spring.resources.chain.cache=true
spring.resources.chain.enabled=true
#spring.resources.chain.gzipped=true
spring.resources.chain.html-application-cache=true
spring.resources.static-locations=classpath:/static/

(六)服务级优化(加入Rabbitmq)

SpringBoot集成Rabbitmq

提前在云服务器上安装好rabbitmq(使用docker安装)
首先添加maven依赖包

<dependency>  <groupId>org.springframework.boot</groupId>  <artifactId>spring-boot-starter-amqp</artifactId>  </dependency>

然后添加配置信息

#rabbitmq
spring.rabbitmq.host=114.132.248.249
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
spring.rabbitmq.virtual-host=/
#\u6D88\u8D39\u8005\u6570\u91CF
spring.rabbitmq.listener.simple.concurrency= 10
spring.rabbitmq.listener.simple.max-concurrency= 10
#\u6D88\u8D39\u8005\u6BCF\u6B21\u4ECE\u961F\u5217\u83B7\u53D6\u7684\u6D88\u606F\u6570\u91CF
spring.rabbitmq.listener.simple.prefetch= 1
#\u6D88\u8D39\u8005\u81EA\u52A8\u542F\u52A8
spring.rabbitmq.listener.simple.auto-startup=true
#\u6D88\u8D39\u5931\u8D25\uFF0C\u81EA\u52A8\u91CD\u65B0\u5165\u961F
spring.rabbitmq.listener.simple.default-requeue-rejected= true
#\u542F\u7528\u53D1\u9001\u91CD\u8BD5
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.0

接下进行简单测试,是否可以连接,是否可以正常传送消息。
创建配置类:

@Configuration
public class MQConfig {public static final String QUEUE_NAME = "queue";@Beanpublic Queue queue(){return new Queue(QUEUE_NAME,true);}
}

@Bean注解就是要告诉方法,产生一个Bean对象,并将这个Bean由Spring容器管理。产生这个Bean对象的方法Spring只会调用一次,随后这个Bean将放在IOC容器中。 SpringIOC容器管理一个或者多个Bean,这些Bean都需要在@Configuration注解下进行创建

创建消息的接收器:

@Service
@Slf4j
public class MQReceiver {@RabbitListener(queues = MQConfig.QUEUE_NAME)public void receive(String message){log.info("receive message:" + message);}
}

@RabbitListener,其中queues属性通过识别队列的名字来接受消息进行消费
创建消息的发送器:

@Service
@Slf4j
public class MQSender {@Autowired//AmqpTemplate接口定义了发送和接收消息的基本操作AmqpTemplate amqpTemplate;public void send(Object message){String msg = RedisService.beanToString(message);log.info("send message:" + msg);amqpTemplate.convertAndSend(MQConfig.QUEUE_NAME,msg);}
}

关于rabbitmq涉及到交换机等等概念,可以细看rabbitmq笔记

秒杀接口优化思路

接口优化,实质上就是去减少数据库的访问
1.系统初始化时,将秒杀商品库存加载到Redis中
2.收到请求,在Redis中预减库存,库存不足时,直接返回秒杀失败
3.秒杀成功,将订单压入消息队列,返回前端消息“排队中”(像12306的买票)
4.消息出队,生成订单,减少库存
5.客户端在以上过程执行过程中,将一直轮询是否秒杀成功

库存预加载到Redis中

这里我们是通过实现InitialzingBean接口,重写其中afterProperties方法达成的

public class MiaoshaController implements InitializingBean {@Overridepublic void afterPropertiesSet() throws Exception {//系统启动的时候,就将数据存入Redis//加载所有秒杀商品List<GoodsVo> goodsVos = goodsService.listGoodsVo();if(goodsVos == null)return;//存入Redis中,各秒杀商品的数量for (GoodsVo good : goodsVos){redisService.set(GoodsKey.miaoshaGoodsStockPrefix,""+good.getId(),good.getStockCount());map.put(good.getId(),false);}}......
}

1.我们先从数据库中将秒杀商品的信息读取出来,再一个一个加载到缓存中
2.注意一下其中有一个map,它添加了对应Id-false的键值对,它表示的是该商品没有被秒杀完,用于下文中,当商品秒杀完,阻止其对redis服务的访问(后文还会提到)

开始秒杀,预减库存

        //user不能为空,空了去登陆if(user == null){return Result.error(CodeMsg.SESSION_ERROR);}//HashMap内存标记,减少Redis访问时间boolean over = map.get(goodsId);if(over)return Result.error(CodeMsg.MIAO_SHA_OVER);//收到请求,预减库存Long count = redisService.decr(GoodsKey.miaoshaGoodsStockPrefix, "" + goodsId);if(count <= 0){map.put(goodsId,true);return Result.error(CodeMsg.MIAO_SHA_OVER);}

1.首先用户不能为空
2.这里我们又看见了map,它写在了Redis服务前边,当商品秒杀完毕的时候,这样就能防止它再去访问Redis服务了
3.预减库存,库存小于0的时候就返回秒杀失败

加入消息队列中(Direct Exchange)

        //判断是否已经秒杀过了MiaoshaOrder miaoshaOrder = orderService.selectMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);if(miaoshaOrder != null)return Result.error(CodeMsg.REPEATE_MIAOSHA);//加入消息队列MiaoshaMessage miaoshaMessage = new MiaoshaMessage();miaoshaMessage.setGoodsId(goodsId);miaoshaMessage.setMiaoShaUser(user);mqSender.sendMiaoshaMessage(miaoshaMessage);

1.在其之前我们有一个判断,判断该用户是不是重复秒杀,其实这一步是多余的,因为我们在数据库中已经建立了唯一索引,将userId和GoodsId绑定在了一起,不会生成重复的订单
2.自定义MiaoshaMessage类,创建对象,其中加入我们想要的user和goodsId信息,并将消息发出去

消息发送过程

    @Autowired// 用SpringBoot框架提供的AmqpTemlplate实例来为我们的秒杀队列发送消息AmqpTemplate amqpTemplate;public void sendMiaoshaMessage(MiaoshaMessage miaoshaMessage){String msg = RedisService.beanToString(miaoshaMessage);log.info("miaosha send msg:" + msg);amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE,msg);}

消息出队处理

判断库存是否还有,有的话,向下执行秒杀

    @RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)public void receiveMiaoshaMsg(String miaoshaMessage){log.info("miaosha receive msg:" + miaoshaMessage);MiaoshaMessage msg = RedisService.stringToBean(miaoshaMessage, MiaoshaMessage.class);long goodsId = msg.getGoodsId();MiaoShaUser miaoShaUser = msg.getMiaoShaUser();GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);//判断库存int stock = goodsVo.getStockCount();if(stock < 0)return;//有库存而且没秒杀过,开始秒杀miaoshaService.miaosha(miaoShaUser,goodsVo);}

秒杀方法

    @Transactionalpublic OrderInfo miaosha(MiaoShaUser user, GoodsVo goods) {//库存减一boolean success = goodsService.reduceStock(goods);if(success)//下订单return orderService.createOrder(user,goods);else{setGoodsOver(goods.getId());return null;}}

1.该方法我们用@Transactional注解标记,保证减库存和下订单都执行成功
2.注意其中有一个setGoodsOver()方法,它的作用是当该商品库存没有的时候,在redis中存一个标志,

private void setGoodsOver(Long goodsId) {redisService.set(MiaoshaKey.isGoodsOver, ""+goodsId, true);}

这里写了一个/resulet请求,前端会根据返回值,来判断秒杀的状态

    /*** orderId 成功* -1 秒杀失败* 0 继续轮询* @param miaoShaUser* @param goodsId* @return*/@RequestMapping(value = "/result",method = RequestMethod.GET)@ResponseBodypublic Result<Long> miaoshaResult(MiaoShaUser miaoShaUser,@RequestParam("goodsId")long goodsId){if(miaoShaUser == null)return Result.error(CodeMsg.SESSION_ERROR);long result = miaoshaService.getMiaoshaResult(miaoShaUser.getId(),goodsId);return Result.success(result);}

getMiaoshaResult方法:

    public long getMiaoshaResult(long userId, long goodsId) {MiaoshaOrder order = orderService.selectMiaoshaOrderByUserIdGoodsId(userId, goodsId);if(order != null){//秒杀成功return order.getOrderId();}else {boolean isOver = getGoodsOver(goodsId);if(isOver)return -1;else//继续轮询return 0;}}

1.用户在秒杀该商品的过程中,在得到秒杀结果之前,会一直进行轮询,直到返回orderId或者-1来告知秒杀成功与失败
2.该方法中,从数据库中看看能不能查询到秒杀订单信息,有说明秒杀成功,返回订单号;失败了则获取redis中的是否秒杀完的标志,跟前边setGoodsOver()相对应,这里的getGoodsOver()便是对set的值进行获取,如果没有库存了则说明秒杀失败了,否则要继续轮询了(已经秒杀到,但是订单还没有创建完成)

(七)图形验证码及恶意防刷

图形验证码

我们在立即秒杀按钮处添加验证码,防止机器人对我们的系统进行多次秒杀,也可以使秒杀能够错峰访问,削减并发量本项目采用的是ScriptEngine,但是实际上开发是使用kaptcha较多!这里只是了解,使用kaptcha来进行重构!

在该方法中,实现的是将从前端获取的验证码与Redis存储的验证码进行验证,验证完成之后,就将它从Redis中移除,方法代码如下

在此之前,前端验证码会和后端有一个响应,每次刷新验证码都会将其的正确结果同步到服务器的Redis上

恶意防刷:动态秒杀地址

之前我们实现秒杀的时候是直接跳转到秒杀接口,使得我们每次的秒杀地址都是一样的,这样具有安全隐患,所以,我们将其改为动态地址,通过在前端上写一个方法进行跳转,如下所示。
它会先跳转到/miaosha/path,获取秒杀地址中的path值,将其存储在Redis中

然后携带path值去访问真正的秒杀方法,在其中将path值与Redis中的值进行比较,一致才能继续秒杀

获取路径的Java代码:

    @ResponseBody@RequestMapping(value = "/path",method = RequestMethod.GET)public Result<String> getMiaoshaPath(MiaoShaUser user,@RequestParam("goodsId")long goodsId,@RequestParam(value = "verifyCode",defaultValue = "0")int verifyCode){if(user == null)return Result.error(CodeMsg.SESSION_ERROR);String path = miaoshaService.createMiaoshaPath(user,goodsId);return Result.success(path);}

先调用createMiaoshaPath()方法,在其中会创建一串随机值,并且存储到Redis中,具体方法如下,执行完之后将路径值返回到前端

    public String createMiaoshaPath(MiaoShaUser user, long goodsId) {if(user == null || goodsId <= 0)return null;String str = MD5Util.md5(UUIDUtil.getUUID());redisService.set(MiaoshaKey.miaoshaPathPrefix,user.getId() + "_" + goodsId,str);return str;}

执行秒杀接口的修改:

路径上,我们采用了RestFul风格,通过@PathVariable注解获取其中的路径值,并与redis服务器中的值进行比较,一致才能向下一步继续执行

恶意防刷:接口限流

接口限流防刷的作用是在规定的时间内访问固定的次数。我们实现的思路是,在要限制防刷的方法上添加注解,通过拦截器进行限制访问次数
创建出这个注解:
该注解中,包含了需要访问时间内的访问次数,以及判断是否需要登录

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {int seconds();int maxCount();boolean needLogin() default true;
}

对我们想要限流的方法进行标记:
创建拦截器:

public class AccessInterceptor extends HandlerInterceptorAdapter {@AutowiredMiaoShaUserService userService;@AutowiredRedisService redisService;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if(handler instanceof HandlerMethod){MiaoShaUser user  = getUser(request,response);UserContext.setUser(user);HandlerMethod hm = (HandlerMethod) handler;//处理方法的对象,获取的是方法的注解AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);if(accessLimit == null){return false;}int seconds = accessLimit.seconds();int maxCount = accessLimit.maxCount();boolean needLogin = accessLimit.needLogin();String key = request.getRequestURI();//获取请求的地址if (needLogin) {if(user == null){//user为空,递交错误信息render(response, CodeMsg.SESSION_ERROR);return false;}key += "_" + user.getId();}AccessKey accessKey = AccessKey.withExpireSecond(seconds);Integer count = redisService.get(accessKey, key, Integer.class);if(count == null){redisService.set(accessKey,key,1);}else if(count < maxCount){redisService.incr(accessKey,key);}else{render(response,CodeMsg.ACCESS_LIMIT_REACHED);return false;}}return true;}......
}


最后配置一下

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter{@AutowiredUserArgumentResolver userArgumentResolver;@AutowiredAccessInterceptor accessInterceptor;@Overridepublic void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {super.addArgumentResolvers(argumentResolvers);argumentResolvers.add(userArgumentResolver);}@Overridepublic void addInterceptors(InterceptorRegistry registry) {InterceptorRegistration interceptorRegistration = registry.addInterceptor(accessInterceptor);interceptorRegistration.addPathPatterns("/miaosha/path");}
}

在这个配置类中,我们重写的是addInterceptors方法,将拦截器注入进来,加到配置中,(指定要拦截的地址这一步可以省略掉了,因为我们使用的是注解标记,前边有一处写错,开始写的是没有注解的话,返回false,这样全局都被拦截了,应该写成true,这样才能放行)

(八)面试题

1. 库存预加载到Redis中是怎么实现的?

我是通过实现InitializingBean接口,重写其中afterPropertiesSet()方法,实现的预加载

1.1 之后主动添加秒杀商品的话,怎么添加?

通过后台管理进行添加,修改redis缓存和数据库中的值

2. 在Redis中扣减库存的时候,是怎么保证线程安全,防止超卖的?

redis中有一个decr()方法,它实现的是递减操作,而且能够保证原子性

3. 如果出现Redis缓存雪崩、穿透,怎么解决?

雪崩就是缓存中我存储的值全部都失效了,请求直接打到数据库上,请求过大,数据库扛不住。可以用设置这些热点数据永不失效,或者是设置一个随机的过期时间,这样来避免它同时失效。

缓存穿透是缓存和数据库中都没有的数据,如果有人利用这些数据高并发的访问的话,对数据库压力也很大。可以对数据比如它的id值进行一个校验,避免这些不存在的值对数据库进行访问或者是使用布隆过滤器,它的原理是通过高效的数据结构查询数据库中是否存在这个值,不存在的时候,就直接返回,存在的话才会访问到数据库。

4. 限流防刷是怎么实现的?

限流防刷我是通过拦截器来实现的,我自定义了一个注解,它实现的功能就是标记在方法上,规定它单位时间内的访问次数,如果超过要求的话,就会被拦截。

拦截器我是继承的HandlerInterceptorAdapter,重写的是preHandle方法,在该方法中,将访问次数同步到Redis中,这个键值对是存在有效期的。最后还要把拦截器配置到项目中,继承WebMvcConfigurerAdapter,重写addInterceptors()方法

5. 对于用户的恶意下单,他知道了你的URL地址,不停的刷,怎么办?

我是通过隐藏URL地址来避免这种问题的,当访问秒杀接口的时候,会先从后端生成一个随机的字符串,然后保存到redis中,并且拼接到URL地址上,这样再去访问秒杀的接口,通过RestFul风格的地址,获取其中的随机字符串,与redis中的进行比对,一致的话,才能继续向下访问

6. 秒杀成功后是怎么同步到数据库中的?

通过两步,一步是减少商品库存,第二步是创建秒杀订单。

6.1 减库存成功,创建秒杀订单失败了怎么办?

这两步过程在一个事务中执行,然后先减少库存,它有一个成功的标志,减少库存成功了,才去执行创建订单的操作

6.2 Spring默认的事务隔离级别

默认情况下Spring使用的是数据库设置的默认隔离级别,应该是可重复读

7. RabbitMQ怎么提高消息的高可用?

我在创建队列实例的时候,将其创建为可持久化的,它有一个durable属性设置为true,这样,RabbitMQ服务重启的情况下,也不会丢失消息。

8. 说说volatile关键字儿

它最重要的一点就是保证了变量的可见性。我想先说说JMM(java内存模型),每个线程有自己的工作内存,另外还存在一个主内存,线程从主内存中获取值存储在自己的工作内存中,当对变量进行修改,它不会立即将其同步到主内中,这个时候若有其他线程来从主内存中获取该变量的时候,就会发生脏读的现象,若被volatile标记的话,就能保证变量的可见性,当变量被修改的时候他就会将其立即同步到主内存中。

9. TCP和UDP的区别

TCP是需要通过三次握手建立连接的;UDP是无连接的
TCP提供的可靠性高;UDP的不保证可靠性,一般用于直播或者是语音通话
TCP是基于字节流的传输层协议,它比较慢;UDP比较快

10. ArrayList

底层是数组,查询快,增删慢
它的默认大小是10,添加值的时候会先对当前数组大小和总大小进行判断,若出现超过最大容量的话,就要进行扩容,扩容的大小是原来大小的1.5倍(右移运算符,右移1位),再将之前的数据复制到新的数组里边。

Java秒杀系统方案优化 高性能高并发实战 学习笔记相关推荐

  1. Java秒杀系统方案优化 高性能高并发实战视频

    链接: https://pan.baidu.com/s/1VrtGT_04EUxlm_zcvnESVw 提取码: wjse 需要其他学习资料或者探讨Java相关开发技术可以关注"冰点IT&q ...

  2. java系统优化方案_Java秒杀系统方案优化 高性能高并发实战-一号门

    类别: 视频 语言: Java 发布日期: 2019-03-02 介绍:以"秒杀"这一Java高性能高并发的试金石场景为例,带你通过一系列系统级优化,学会应对高并发. 第1章 课程 ...

  3. java架构知识点-大数据与高并发(学习笔记)

    大数据与高并发 一.秒杀架构设计 业务介绍 什么是秒杀?通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动 比如说京东秒杀,就是一种定时定量秒杀,在规定的时间内,无论商品是否秒杀完毕,该场次的秒 ...

  4. Java秒杀系统方案优化【第三方登录】

    一.qq登录 1.申请开发者账号 2.依赖 <dependency><groupId>org.apache.commons</groupId><artifac ...

  5. 【在线网课】Java高性能高并发秒杀系统方案优化实战

    java教程视频讲座简介: Java高性能高并发秒杀系统方案优化实战 Java秒杀系统方案优化 高性能高并发实战 以"秒杀"这一Java高性能高并发的试金石场景为例,带你通过一系列 ...

  6. java后验条件_JAVA并发实战学习笔记——3,4章~

    JAVA并发实战学习笔记 第三章 对象的共享 失效数据: java程序实际运行中会出现①程序执行顺序对打乱:②数据对其它线程不可见--两种情况 上述两种情况导致在缺乏同步的程序中出现失效数据这一现象, ...

  7. java 秒杀 源码 下载_java高并发秒杀系统3-4节秒杀功能实现.mp4

    本Java商城秒杀系统视频教程目录如下:    java高并发秒杀系统1-1节java高并发商城秒杀优化学习指引.mp4 java高并发秒杀系统1-2节项目环境搭建(Eclipse)-节.mp4 ja ...

  8. java调优 视频_Java优化高性能高并发+高并发程序设计视频教程

    转自:https://www.cnblogs.com/ajianku/p/10236573.html 第1章 课程介绍及项目框架搭建 1-1 Java高并发商城秒杀优化导学 1-2 项目环境搭建(Ec ...

  9. Java高性能高并发实战之页面优化技术(五)

    文章目录 前言 正文 增加缓存 页面静态化,前后端分离 页面缓存 实际操作 URL缓存 对象缓存 具体实现思想 具体实现过程 页面静态化 后记 前言 此篇文章是系列的第五章篇文章,具体文章目录: 章节 ...

最新文章

  1. 用TensorFlow训练第一个模型
  2. linux下各种软件安装方法详解
  3. php+循环定时任务,php定时任务循环执行replace操作无故中断
  4. B. Light It Up
  5. 什么导致了交换机端口呈现err-disable状态?
  6. js 序列化内置对象_内置序列化技术
  7. oracle cronb,利用Crontab实现对Oracle数据库的定时备份
  8. OpenCV中基本数据结构(8)_Complex
  9. Python知识点之Python面向对象
  10. 数据结构之栈的应用(语法匹配)
  11. 多机器人系统实验室汇总
  12. Vue中swiper的指向性跳转~轮播图与标题的互动
  13. 计算机usb接口无法充电,电脑可充电USB接口不能使用怎么办
  14. 劳伦斯.拉里.埃里森(甲骨文公司总裁)在耶鲁大学的演讲稿
  15. c++中new是否会自动初始化
  16. ERROR: Could not build wheels for numpy which use PEP 517 and cannot be installed directly
  17. Android特效专辑(八)——实现心型起泡飞舞的特效,让你的APP瞬间暖心
  18. python定义多项式除法_python如何进行多项式的加减乘除
  19. CEO,CTO,COO,CFO,CIO首席执行官,首席运营官,首席技术官
  20. 计算机网络:网间互联协议

热门文章

  1. mysql多表左联分组查询
  2. 小度助手和它背后的百度AI野望
  3. 惠普战66六代酷睿版和锐龙版区别对比评测哪个好
  4. Feign底层原理分析-自动装载动态代理
  5. redis客户端连接
  6. Spring中的getBean
  7. 腾讯SNG全链路日志监控平台之构建挑战
  8. wind7 下面组建共享宽带上网局域网 利用wifi热点
  9. 青年艺术家孙亮联名NFT数字藏品会员卡最新上线“数字藏品会员”微信小程序
  10. 每日学一个设计模式10——策略模式