秒杀系统设计

技术栈

业务逻辑

项目介绍

项目框架搭建

  • 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/

  1. 添加pom依赖:mybatis-spring-boot-starter
  2. 添加配置: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

  1. 用户端:PASS = MD5(明文+固定salt)

    防止用户密码在网络上明文传输

  2. 服务端: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包

页面优化技术

  1. 页面缓存+URL缓存+对象缓存
  2. 页面静态化,前后端分离
  3. 静态资源优化
  4. CDN优化

页面缓存

  1. 取缓存
  2. 手动渲染模板
  3. 结果输出
//添加注解属性	
@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;
    }

页面优化

前后端分离,让浏览器缓存页面

解决卖超

  1. 数据库加唯一索引:防止用户重复购买
  2. SQL加库存数量判断:防止库存变成负数

执行减少库存的sql语句加上判断库存大于0

在数据库中,用户id和商品id建立唯一索引,以此保证不会重复插入数据,同一个用户不会重复买

ALTER TABLE `miaosha`.`miaosha_order` 
ADD UNIQUE INDEX `index_uid_gid` (`user_id` ASC, `goods_id` ASC);

小优化

查询是否秒杀到时,可以不用查数据库,在插入订单的同时,将秒杀的信息添加到缓存中,从缓存中来判断是否秒杀到了商品

静态资源优化

  1. JS/CSS压缩,减少流量

  2. 多个JS/CSS组合,减少连接数

    Tengine,webpack

  3. CDN就近访问

接口优化

  1. Redis预减库存减少数据库访问
  2. 内存标记减少Redis访问
  3. 请求先入队列,异步下单,增强用户体验
  4. RabbitMQ安装与SpringBoot集成
  5. Nginx水平扩展
  6. 压测

mycat(阿里巴巴开源的分库分表中间件)

集成RabbitMQ

安装erlang

安装Rabbit MQ

启动RabbitMQ

  1. ./rabbitmq-server启动rabbitMQ server
  2. netstat -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

秒杀接口优化

思路:减少数据库访问

  1. 系统初始化,把商品库存数量加载到Redis
  2. 收到请求,Redis预减库存,库存不足,直接返回,否则进入3
  3. 请求入队,立即返回排队中
  4. 请求出队,生成订单,减少库存
  5. 客户端轮询,是否秒杀成功

安全优化

  1. 秒杀接口地址隐藏
  2. 数学公式验证码
  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;
    }

数学公式验证码

  1. 添加生成验证码的接口
  2. 在获取秒杀路径的时候,验证验证码
  3. 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()+"&timestamp="+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(...){..}

END

posted @ 2019-12-24 21:14  它山之玉  阅读(452)  评论(0编辑  收藏  举报