秒杀系统设计
技术栈
业务逻辑
项目介绍
项目框架搭建
- SpringBoot环境搭建
- 集成Thymeleaf,Result结果封装
- 集成Mybatis+Druid
- 集成Jedis+Redis安装+通用缓存Key封装
实现登陆功能
- 数据库设计
- 明文密码两次MD5处理
- JSR303参数检验+全局异常处理器
- 分布式Session
实现秒杀功能
- 数据库设计
- 商品列表页
- 商品详情页
- 订单详情页
JMeter压测
- JMeter入门
- 自定义变量模拟多用户
- JMeter命令行使用
- SpringBoot打war包
页面优化技术
- 页面缓存+URL缓存+对象缓存
- 页面静态化,前后端分离
- 静态资源优化
- CDN优化
接口优化
- Redis预减库存减少数据库访问
- 内存标记减少Redis访问
- RabbitMQ队列缓冲,异步下单,增强用户体验
- RabbitMQ安装与SpringBoot集成
- 访问Nginx水平扩展
- 压测
安全优化
- 秒杀接口地址隐藏
- 数学公式验证码
- 接口防刷
项目框架搭建
返回封装类
CodeMsg
package com.qiankai.miaosha.result;
import lombok.Data;
@Data
public class CodeMsg {
private int code;
private String msg;
//通用异常
public static CodeMsg SUCCESS = new CodeMsg(0,"success");
public static CodeMsg SERVER_ERROR = new CodeMsg(50010,"服务端异常");
public static CodeMsg BIND_ERROR = new CodeMsg(500101, "参数校验异常:%s");
//登陆模块 5002XX
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 USER_NOT_EXIST = new CodeMsg(500214, "用户不存在");
public static CodeMsg PASSWORD_ERROR = new CodeMsg(500215, "密码错误");
//商品模块 5003XX
//订单模块 5004XX
//秒杀模块 5005XX
public CodeMsg(int code, String msg){
this.code = code;
this.msg = msg;
}
public CodeMsg fillArgs(Object... args) {
int code = this.code;
String message = String.format(this.msg, args);
return new CodeMsg(code, message);
}
}
Result
package com.qiankai.miaosha.result;
import lombok.Data;
@Data
public class Result<T> {
private int code;
private String msg;
private T data;
/**
* 成功时候的调用
*/
public static <T> Result<T> success(T data){
return new Result<>(data);
}
/**
* 失败时候的调用
*/
public static <T> Result<T> error(CodeMsg codeMsg) {
return new Result<>(codeMsg);
}
public Result(T data){
this.code=0;
this.msg = "success";
this.data = data;
}
public Result(CodeMsg codeMsg) {
if (codeMsg == null) {
return;
}
this.code = codeMsg.getCode();
this.msg = codeMsg.getMsg();
}
}
集成mybatis
http://www.mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/
- 添加pom依赖:mybatis-spring-boot-starter
- 添加配置:mybatis.*
建表
CREATE TABLE `miaosha`.`user` (
`id` INT NOT NULL AUTO_INCREMENT ,
`name` VARCHAR(45) NULL,
PRIMARY KEY (`id`))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8;
application.properties
# thymeleaf
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
mybatis.type-aliases-package=com.qiankai.miaosha.domain
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.configuration.default-fetch-size=100
mybatis.configuration.default-statement-timeout=3000
mybatis.mapper-locations=classpath:com/qiankai/miaosha/dao/*.xml
# druid
spring.datasource.url=jdbc:mysql://localhost:3306/miaosha?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT
spring.datasource.username=root
spring.datasource.password=qian1998
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.filters=stat
spring.datasource.maxActive=2
spring.datasource.initialSize=2
spring.datasource.maxWait=60000
spring.datasource.minIdle=1
spring.datasource.timeBetweenEvictionRunsMillis=60000
spring.datasource.minEvictableIdleTimeMillis=300000
spirng.datasource.validationQuery=select 'x'
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
spring.datasource.poolPreparedStatements=true
spring.datasource.maxOpenPreparedStatement=20
# redis
redis.host = 101.132.194.42
redis.port = 6379
redis.timeout = 3
redis.password = 1234569
redis.poolMaxTotal = 10
redis.poolMaxIdle = 10
redis.poolMaxWait = 3
#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/
集成Redis
服务端
修改redis.conf文件
bind 127.0.0.1 ----> bind 0.0.0.0
daemonize no -----> deamonize yes
#修改密码
requirepass foobared ----> requirepass qian1998
启动redis-server
redis-server ./redis.conf
# 关闭redis
redis-cli
127.0.0.1:6379> shutdown save
not connected> exit
#修改密码后重新启动
redis-server ./redis.conf
#操作需要密码,先输入密码
root@iZuf688rg4xz5o91dx3eptZ:/usr/local/redis# redis-cli
127.0.0.1:6379> get key
(error) NOAUTH Authentication required.
127.0.0.1:6379> auth qian1998
OK
127.0.0.1:6379> get key
(nil)
127.0.0.1:6379>
# 生成服务 执行 utils/install_server.sh,并设置
conf目录:/usr/local/redis/redis.conf
log目录:/user/local/redis/redis.log
数据目录:/user/local/redis/data
#查看服务
chkconfig --list | grep redis
项目代码
application.properties
# redis
redis.host = 101.132.194.42
redis.port = 6379
redis.timeout = 3
redis.password = 1234569
redis.poolMaxTotal = 10
redis.poolMaxIdle = 10
redis.poolMaxWait = 3
RedisConfig-用于读取properties中的redis配置
package com.qiankai.miaosha.redis;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@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;
}
RedisPoolFactory-生成数据库连接池
RedisService-封装redis的操作
生成Redis的key
真正存入redis的key为 对应类型的前缀+key
建立接口 KeyPrefix
public interface KeyPrefix {
int expireSeconds(); //获取过期时间
String getPrefix(); //获取前缀
}
建立抽象类 BasePrefix 实现 KeyPrefix
public abstract class BasePrefix implements KeyPrefix {
private int expireScconds;
private String prefix;
public BasePrefix(String prefix) {
this(0,prefix); //0表示永不过期
}
public BasePrefix(int expireScconds, String prefix) {
this.expireScconds = expireScconds;
this.prefix = prefix;
}
@Override
public int expireSeconds() {
return expireScconds;
}
@Override
public String getPrefix() {
String className = getClass().getSimpleName();
return className +":"+ prefix;
}
}
建立不同类型的key的实现类,继承BasePrefix,例如
//User存入redis的前缀
public class UserKey extends BasePrefix {
public UserKey(String prefix) {
super(prefix);
}
public static UserKey getById = new UserKey("id");
public static UserKey getByName = new UserKey("name");
}
//MiaoshaUser存入redis的前缀
public class MiaoshaUserKey extends BasePrefix {
private static final int TOKEN__EXPIRE=3600*24*2;
public MiaoshaUserKey(int expireScconds, String prefix) {
super(expireScconds, prefix);
}
public static MiaoshaUserKey getByToken = new MiaoshaUserKey(TOKEN__EXPIRE, "token");
}
实现登陆功能
数据库设计
CREATE TABLE `miaosha`.`miaosha_user` (
`id` BIGINT(20) NOT NULL COMMENT '用户id,手机号码',
`nickname` VARCHAR(255) NOT NULL,
`password` VARCHAR(32) NULL COMMENT 'MD5(MD5(pass明文+固定salt)+salt)',
`salt` VARCHAR(10) NULL,
`head` VARCHAR(128) NULL COMMENT '头像,云存储的ID',
`register_date` DATETIME NULL COMMENT '注册时间',
`last_login_date` DATETIME NULL COMMENT '上次登陆时间',
`login_count` INT(11) NULL DEFAULT 0 COMMENT '登陆次数',
PRIMARY KEY (`id`))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8mb4;
两次MD5
-
用户端:PASS = MD5(明文+固定salt)
防止用户密码在网络上明文传输
-
服务端:PASS = MD5(用户输入+随机salt)
防止彩虹表反查MD5进行解密
public class MD5Util {
private static final String salt = "1a2b3c4d";
public static String md5(String src) {
return DigestUtils.md5Hex(src);
}
/**
* 将用户输入的密码 使用公共salt 转换为加密后的密码,用于在网络中传输,前端也有对应实现,此处为了方便进行二次加密
*/
public static String inputPassFormPass(String inputPass) {
String str = "" + salt.charAt(0) + salt.charAt(4) + inputPass + salt.charAt(2) + salt.charAt(3);
return md5(str);
}
/**
* 将加密过一次的密码 使用随机的salt 再加密一次,用于存入数据库
*/
public static String formPassToDBPass(String formPass, String salt) {
String str = "" + salt.charAt(0) + salt.charAt(4) + formPass + salt.charAt(2) + salt.charAt(3);
return md5(str);
}
/**
* 将用户输入的原密码直接转换为数据库的密码,测试用
*/
public static String inputPassToDBPass(String input, String saltDB) {
String formPass = inputPassFormPass(input);
String dbPass = formPassToDBPass(formPass, saltDB);
return dbPass;
}
}
JSR303参数检验
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
建立工具类ValidatorUtil
用于验证手机号的格式
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();
}
}
新建一个注解@IsMobile
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@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 { };
}
建立验证类
public class IsMobileValidator implements ConstraintValidator<IsMobile,String> {
private boolean required = false; //false表示不用校验
@Override
public void initialize(IsMobile isMobile) {
required = isMobile.required();
}
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
if (required) { //如果为true,则要校验值,检验手机号
return ValidatorUtil.isMobile(s);
} else if (StringUtils.isEmpty(s)) {//不用校验值,并且传入了空值
return true;
} else { //不用校验值,但传入了一个值需要校验
return ValidatorUtil.isMobile(s);
}
}
}
将注解添加到对应属性上
//controller加上@Valid注解
public Result<Boolean> doLogin(@Valid LoginVo loginVo)
//对应的封装类加上注解
@Data
public class LoginVo {
@NotNull
@IsMobile
private String mobile;
@NotNull
private String password;
}
全局异常处理器
分布式Session
将cookie存在独立机器中的redis中
建立工具类UUIDUtil
生成cookie中的token
public class UUIDUtil {
public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}
秒杀功能
数据库设计
商品表,订单表,秒杀商品表,秒杀订单表
CREATE TABLE goods (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
goods_name VARCHAR(16) DEFAULT null comment '商品名称',
goods_title varchar(64) DEFAULT null comment '商品标题',
goods_img varchar(64) DEFAULT null comment '商品的图片',
goods_detail LONGTEXT COMMENT '商品的详情介绍',
goods_price DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品单价',
goods_stock int(11) DEFAULT '0' COMMENT '商品库存,-1表示没有限制',
primary key (id)
)ENGINE=INNODB auto_increment=3 DEFAULT charset=utf8mb4;
INSERT INTO goods VALUES (1,'iphoneX','Apple iphone X (A1865) 64GB 银色 移动联通电信4G手机','/img/iphonex.png','Apple iphone X (A1865) 64GB 银色 移动联通电信4G手机',8765.00,10000),(2,'华为Meta9','华为Meta9 4GB+32GB版 月光银 移动联通电信4G手机 双卡双待','/img/meta9.png','华为Meta9 4GB+32GB版 月光银 移动联通电信4G手机 双卡双待',3212.00,-1);
CREATE TABLE miaosha_goods (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀的商品表',
goods_id bigint(20) DEFAULT null comment '商品id',
miaosha_price DECIMAL(10,2) DEFAULT '0.00' COMMENT '秒杀价',
stock_count int(11) DEFAULT null COMMENT '库存数量',
start_date datetime DEFAULT null COMMENT '秒杀开始时间',
end_date datetime DEFAULT null COMMENT '秒杀结束时间',
primary key (id)
)ENGINE=INNODB auto_increment=3 DEFAULT charset=utf8mb4;
insert into miaosha_goods values (1,1,0.01,4,'2018-11-05 15:18:00','2018-11-13 14:00:18'),(2,2,0.01,9,'2018-11-12 14:00:14','2018-11-13 14:00:24');
CREATE TABLE order_info (
id bigint(20) NOT NULL AUTO_INCREMENT,
user_id bigint(20) DEFAULT null comment '用户ID',
goods_id bigint(20) default null comment '商品ID',
delivery_addr_id bigint(20) default null comment '收货地址ID',
goods_name VARCHAR(16) DEFAULT null comment '冗余过来的商品名称',
goods_count int(11) DEFAULT '0' comment '商品数量',
goods_price DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品单价',
order_channel tinyint(4) DEFAULT '0' comment '1pc,2android,3ios',
status tinyint(4) DEFAULT '0' comment '订单状态,0新建未支付,1已支付,2已发货,3已收货,4已退款,5已完成',
create_date datetime DEFAULT null comment '订单的创建时间',
pay_date datetime DEFAULT null comment '支付时间',
primary key (id)
)ENGINE=INNODB auto_increment=12 DEFAULT charset=utf8mb4;
CREATE TABLE miaosha_order (
id bigint(20) NOT NULL AUTO_INCREMENT,
user_id bigint(20) DEFAULT null comment '用户ID',
order_id bigint(20) default null comment '订单ID',
goods_id bigint(20) default null comment '商品ID',
primary key (id)
)ENGINE=INNODB auto_increment=3 DEFAULT charset=utf8mb4;
页面设计
商品列表页
商品详情页
订单详情页
JMeter压测
JMeter入门
自定义变量模拟多用户
JMeter命令行使用
Redis压测工具redis-benchmark
SpringBoot打war包
页面优化技术
- 页面缓存+URL缓存+对象缓存
- 页面静态化,前后端分离
- 静态资源优化
- CDN优化
页面缓存
- 取缓存
- 手动渲染模板
- 结果输出
//添加注解属性
@RequestMapping(value = "/to_list",produces = "text/html")
@ResponseBody
public String list(Model model, MiaoshaUser user,
HttpServletRequest request, HttpServletResponse response) {
model.addAttribute("user",user);
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList", goodsList);
//return "goods_list";
//先尝试取缓存
String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
if (!StringUtils.isEmpty(html)) {
return html;
}
//取不到再进行渲染,并存入缓存
SpringWebContext cwt = new SpringWebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap(), applicationContext);
//手动渲染
html = thymeleafViewResolver.getTemplateEngine().process("goods_list", cwt);
if (!StringUtils.isEmpty(html)) {
redisService.set(GoodsKey.getGoodsList, "", html);
}
return html;
}
URL缓存
@RequestMapping(value = "/to_detail/{goodsId}",produces = "text/html")
@ResponseBody
public String detail(Model model, MiaoshaUser user, @PathVariable("goodsId") long goodsId,
HttpServletRequest request, HttpServletResponse response) {
model.addAttribute("user", user);
//取缓存
String html = redisService.get(GoodsKey.getGoodsDetail, ""+goodsId, String.class);
if (!StringUtils.isEmpty(html)) {
return html;
}
/*
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goods);
// ...
// return "goods_detail";
*/
//开始渲染
SpringWebContext cwt = new SpringWebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap(), applicationContext);
//手动渲染
html = thymeleafViewResolver.getTemplateEngine().process("goods_detail", cwt);
if (!StringUtils.isEmpty(html)) {
redisService.set(GoodsKey.getGoodsDetail, ""+goodsId, html);
}
return html;
}
对象缓存
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;
}
//更新密码,因为getById可能有缓存,所以取user可能不用通过数据库就能得到,效率提高;更新完信息后要同时更新缓存中的信息
public boolean updatePassword(String token,long id, String passwordNew) {
//取user
MiaoshaUser user = getById(id);
if (user == null) {
throw new GlobalException(CodeMsg.USER_NOT_EXIST);
}
//更新数据库
MiaoshaUser toBeUpdate = new MiaoshaUser();
toBeUpdate.setId(id);
toBeUpdate.setPassword(MD5Util.formPassToDBPass(passwordNew, user.getSalt()));
miaoshaUserDAO.update(toBeUpdate);
//处理缓存
redisService.delete(MiaoshaUserKey.getById, "" + id);
user.setPassword(toBeUpdate.getPassword());
redisService.set(MiaoshaUserKey.getByToken, token, user);
return true;
}
页面优化
前后端分离,让浏览器缓存页面
解决卖超
- 数据库加唯一索引:防止用户重复购买
- SQL加库存数量判断:防止库存变成负数
执行减少库存的sql语句加上判断库存大于0
在数据库中,用户id和商品id建立唯一索引,以此保证不会重复插入数据,同一个用户不会重复买
ALTER TABLE `miaosha`.`miaosha_order`
ADD UNIQUE INDEX `index_uid_gid` (`user_id` ASC, `goods_id` ASC);
小优化
查询是否秒杀到时,可以不用查数据库,在插入订单的同时,将秒杀的信息添加到缓存中,从缓存中来判断是否秒杀到了商品
静态资源优化
-
JS/CSS压缩,减少流量
-
多个JS/CSS组合,减少连接数
Tengine,webpack
-
CDN就近访问
接口优化
- Redis预减库存减少数据库访问
- 内存标记减少Redis访问
- 请求先入队列,异步下单,增强用户体验
- RabbitMQ安装与SpringBoot集成
- Nginx水平扩展
- 压测
mycat(阿里巴巴开源的分库分表中间件)
集成RabbitMQ
安装erlang
安装Rabbit MQ
启动RabbitMQ
./rabbitmq-server
启动rabbitMQ servernetstat -nap | grep 5672
SpringBoot集成RabbitMQ
添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
application.properties
#rabbitmq
spring.rabbitmq.host=101.132.194.42
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
spring.rabbitmq.virtual-host=/
spring.rabbitmq.listener.simple.concurrency=10
spring.rabbitmq.listener.simple.max-concurrency=10
spring.rabbitmq.listener.simple.prefetch=1
spring.rabbitmq.listener.simple.auto-startup=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.0
远程连接MQ
# 进入目录
/usr/local/rabbitmq/etc/rabbitmq
# 修改/添加配置文件 rabbitmq.config,添加如下内容
[{rabbit, [{loopback_users,[]}]}].
# 重启RabbitMQ
秒杀接口优化
思路:减少数据库访问
- 系统初始化,把商品库存数量加载到Redis
- 收到请求,Redis预减库存,库存不足,直接返回,否则进入3
- 请求入队,立即返回排队中
- 请求出队,生成订单,减少库存
- 客户端轮询,是否秒杀成功
安全优化
- 秒杀接口地址隐藏
- 数学公式验证码
- 接口限流防刷
秒杀接口地址隐藏
前端秒杀之前先获取一个path
function getMiaoshaPath() {
var goodsId = $("#goodsId").val();
g_showLoading();
$.ajax({
url:"/miaosha/path",
type:"GET",
data:{
goodsId:$("#goodsId").val()
},
success:function () {
if(data.code==0){
var path=data.data;
//获取了path再调用秒杀接口
doMiaosha(path);
}else{
layer.msg(data.msg);
}
},
error:function () {
layer.msg("客户端请求有误")
}
});
}
function doMiaosha(path){
//将path放到路径中,传给后台
$.ajax({
url:"/miaosha/"+path+"/do_miaosha",
type:"POST",
data:{
goodsId:$("#goodsId").val(),
},
success:function(data){
if(data.code == 0){
// window.location.href="/order_detail.htm?orderId="+data.data.id;
getMiaoshaResult($("#goodsId").val());
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}
将获取的path放到请求路径中传递给后台,看代码注释
//获取path
@RequestMapping(value = "/path",method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(Model model,MiaoshaUser user,
@RequestParam("goodsId") long goodsId){
model.addAttribute("user", user);
if (user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
String path = miaoshaGoodsService.createMiaoshaPath(user,goodsId);
return Result.success(path);
}
//秒杀时验证path
@RequestMapping(value = "/{path}/do_miaosha",method = RequestMethod.POST)
@ResponseBody
public Result<Integer> doMiaosha(Model model, MiaoshaUser user,
@RequestParam("goodsId") long goodsId,
@PathVariable("path") String path) {
model.addAttribute("user", user);
if (user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//验证path
boolean check = miaoshaGoodsService.checkPath(user,goodsId,path);
if (!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
//...
}
//miaoshaGoodsService
public boolean checkPath(MiaoshaUser user,long goodsId,String path){
if (user == null || path == null) {
return false;
}
//从redis中取出path并比较
String pathOld = redisService.get(MiaoshaKey.getMiaoshaPath, "" + user.getId() + "_" + goodsId, String.class);
return path.equals(pathOld);
}
public String createMiaoshaPath(MiaoshaUser user,long goodsId){
//生成path并存入redis
String str = MD5Util.md5(UUIDUtil.uuid() + "123456");
redisService.set(MiaoshaKey.getMiaoshaPath, "" + user.getId() + "_" + goodsId, str);
return str;
}
数学公式验证码
- 添加生成验证码的接口
- 在获取秒杀路径的时候,验证验证码
- ScriptEngine使用
添加验证码
<div class="row">
<div class="form-inline">
<img id="verifyCodeImg" style="display: none;width: 80px;height: 32px;" onclick="refreshVerifyCode()"/>
<input id="verifyCode" class="form-control" style="display: none;"/>
<button class="btn btn-primary" type="button" id="buyButton" onclick="getMiaoshaPath()">立即秒杀</button>
</div>
</div>
在秒杀的时候加上验证码
//倒计时
function countDown(){
var remainSeconds = $("#remainSeconds").val();
var timeout;
if(remainSeconds > 0){//秒杀还没开始,倒计时
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀倒计时:"+remainSeconds+"秒");
timeout = setTimeout(function(){
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if(remainSeconds == 0){//秒杀进行中
$("#buyButton").attr("disabled", false);
if(timeout){
clearTimeout(timeout);
}
$("#miaoshaTip").html("秒杀进行中");
//生成验证码
$("#verifyCodeImg").attr("src", "/miaosha/verifyCode?goodsId=" + $("#goodsId").val());
$("#verifyCodeImg").show();
$("#verifyCode").show();
}else{//秒杀已经结束
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀已经结束");
$("#verifyCodeImg").hide();
$("#verifyCode").hide();
}
}
//这一行,加载验证码图片
$("#verifyCodeImg").attr("src", "/miaosha/verifyCode?goodsId=" + $("#goodsId").val());
//刷新验证码,解决浏览器缓存问题要加上时间戳
function refreshVerifyCode() {
$("#verifyCodeImg").attr("src", "/miaosha/verifyCode?goodsId=" + $("#goodsId").val()+"×tamp="+new Date().getTime());
}
后端代码
//生成验证码图片返回给前端
@RequestMapping(value = "/verifyCode", method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaVerifyCode(HttpServletResponse response, MiaoshaUser user,
@RequestParam("goodsId") long goodsId) {
if (user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
BufferedImage image = miaoshaGoodsService.createVerifyCode(user,goodsId);
try {
OutputStream out = response.getOutputStream();
ImageIO.write(image, "JPEG", out);
out.flush();
out.close();
return null;//图片在IO中
} catch (Exception e) {
e.printStackTrace();
return Result.error(CodeMsg.MIAOSHA_FAILED);
}
}
//MiaoshaGoodsService生成图片
public BufferedImage createVerifyCode(MiaoshaUser user, long goodsId) {
if (user == null || goodsId < 0) {
return null;
}
int width=80;
int height=32;
//create the image
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
//set the background color
g.setColor(new Color(0xDCDCDC));
g.fillRect(0, 0, width, height);
//draw the border
g.setColor(Color.black);
g.drawRect(0, 0, width - 1, height - 1);
//create a random instance to generate the codes
Random rdm = new Random();
//make some confusion
for (int i = 0; i < 50; i++) {
int x = rdm.nextInt(width);
int y = rdm.nextInt(height);
g.drawOval(x, y, 0, 0);
}
//generate a random code
String verifyCode = generateVerifyCode(rdm);
g.setColor(new Color(0, 100, 0));
g.setFont(new Font("Candara", Font.BOLD, 24));
g.drawString(verifyCode, 8, 24);
g.dispose();
//把验证码存到redis中
int rnd = calc(verifyCode);
redisService.set(MiaoshaKey.getMiaoshaVerifyCode, user.getId() + "," + goodsId, rnd);
//输出图片
return image;
}
//+ - *
private static char[] ops = new char[]{'+', '-', '*'};
private String generateVerifyCode(Random rdm){
int num1 = rdm.nextInt(10);
int num2 = rdm.nextInt(10);
int num3 = rdm.nextInt(10);
char opt1 = ops[rdm.nextInt(3)];
char opt2 = ops[rdm.nextInt(3)];
String exp = "" + num1 + opt1 + num2 + opt2 + num3;
return exp;
}
private static int calc(String exp) {
try {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("JavaScript");
return (Integer) engine.eval(exp);
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}
验证验证码的结果
//秒杀之前添加检查验证码
@RequestMapping(value = "/path",method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(Model model,MiaoshaUser user,
@RequestParam("goodsId") long goodsId,
@RequestParam("verifyCode") int verifyCode){
model.addAttribute("user", user);
if (user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//检查验证码
boolean check = miaoshaGoodsService.checkVerifyCode(user,goodsId,verifyCode);
//...
}
//MiaoshaGoodsService检查验证码
public boolean checkVerifyCode(MiaoshaUser user, long goodsId, int verifyCode) {
if (user == null || goodsId < 0) {
return false;
}
Integer codeOld = redisService.get(MiaoshaKey.getMiaoshaVerifyCode, user.getId() + "," + goodsId, Integer.class);
if (codeOld == null || codeOld - verifyCode != 0) {
return false;
}
redisService.delete(MiaoshaKey.getMiaoshaVerifyCode, user.getId() + "," + goodsId);
return true;
}
接口防刷
思路:对接口做限流
假设5秒钟限制访问5次,将key存入redis,过期时间为5秒
//检查访问次数,5秒钟只能访问5次
String uri = request.getRequestURI();
String key = uri + "_" + user.getId();
Integer count = redisService.get(AccessKey.access, key, Integer.class);
if (count == null) {
redisService.set(AccessKey.access, key, 1);
} else if (count < 5) {
redisService.incr(AccessKey.access, key);
} else {
return Result.error(CodeMsg.ACCESS_LIMIT_REACHED);
}
通用方法:新建注解,使用拦截器判断
注解AccessLimit
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
int seconds();
int maxCount();
boolean needLogin() default true;
}
新建拦截器AccessInterceptor,获取注解中的值,请求访问前进行处理
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
//这里获取用户,并存入userHolder
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();
}
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;
}
在webConfig中注册拦截器
在使用时只需要在方法前加注解就行
/**
* 获取path接口
*/
@AccessLimit(seconds=5,maxCount=5,needLogin=true)
...
public Result<String> getMiaoshaPath(...){...}
/**
* orderId:秒杀成功
* -1:秒杀失败
* 0:排队中
*/
@AccessLimit(seconds=5,maxCount=10,needLogin=true)
...
public Result<Long> miaoshaResult(...){..}