秒杀系统小demo

秒杀系统小demo

B站秒杀项目视频,学习所做笔记记录,有一丢丢自己的扩展

本文只重点记录项目优化过程,其他详情请观看上述视频 ⬆ 以及文末最终版代码

本秒杀项目所涉及的技术点

image-20220731190052019

如何设计一个秒杀系统

秒杀,对我们来说,都不是一个陌生的东西。每年的双11,618以及时下流行的直播等等。然而秒杀,这对于我们系统而言是一个巨大的考验。

那么,如何才能更好地理解秒杀系统呢?我觉得作为一个程序员,你首先需要从高维度出发,从整体上思考问题。在我看来,秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。

其实,秒杀的整体架构可以概括为稳、准、快几个关键字。

所谓“稳”,就是整个系统架构要满足高可用,流量符合预期时肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提。然后就是“准”,就是秒杀 10 台 iPhone,那就只能成交 10 台,多一台少一台都不行。一旦库存不对,那平台就要承担损失,所以“准”就是要求保证数据的一致性。最后再看“快”,“快”其实很好理解,它就是说系统的性能要足够高,否则你怎么支撑这么大的流量呢?不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。

所以从技术角度上看“稳、准、快”,就对应了我们架构上的高可用、一致性和高性能的要求

  • 高性能。 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。对应的方案比如动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化
  • 一致性。 秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为“拍下减库存”“付款减库存”以及预扣等几种,在大并发更新的过程中都要保证数据的准确性,其难度可想而知
  • 高可用。 现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 PlanB 来兜底,以便在最坏情况发生时仍然能够从容应对。

——乐字节


项目所需所有sql

-- 创建表结构
CREATE TABLE t_user(
	`id` BIGINT(20) NOT NULL COMMENT '用户ID,手机号码',
	`nickname` VARCHAR(255) not NULL,
	`password` VARCHAR(32) DEFAULT NULL COMMENT 'MD5(MD5(pass明文+固定salt)+salt)',
	`salt` VARCHAR(10) DEFAULT NULL,
	`head` VARCHAR(128) DEFAULT NULL COMMENT '头像',
	`register_date` datetime DEFAULT NULL COMMENT '注册时间',
	`last_login_date` datetime DEFAULT NULL COMMENT '最后一次登录事件',
	`login_count` int(11) DEFAULT '0' COMMENT '登录次数',
	PRIMARY KEY(`id`)
)
COMMENT '用户表';

CREATE TABLE t_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)
)
COMMENT '商品表';

CREATE TABLE `t_order` (
	`id` BIGINT(20) NOT NULL  AUTO_INCREMENT COMMENT '订单ID',
	`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(20) DEFAULT '0'  COMMENT '商品数量',
	`goods_price` DECIMAL(10,2) DEFAULT '0.00'  COMMENT '商品价格',
	`order_channel` TINYINT(4) DEFAULT '0'  COMMENT '1 pc,2 android, 3 ios',
	`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 DEFAULT CHARSET = utf8mb4;
COMMENT '订单表';

CREATE TABLE `t_seckill_goods`(
	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀商品ID',
	`goods_id` BIGINT(20) NOT NULL COMMENT '商品ID',
	`seckill_price` DECIMAL(10,2) NOT NULL COMMENT '秒杀价格',
	`stock_count` INT(10) NOT NULL  COMMENT '库存数量',
	`start_date` datetime NOT NULL  COMMENT '秒杀开始时间',
	`end_date` datetime NOT NULL COMMENT '秒杀结束时间',
	PRIMARY KEY(`id`)
)ENGINE = INNODB DEFAULT CHARSET = utf8mb4
COMMENT '秒杀商品表';

CREATE TABLE `t_seckill_order` (
	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀订单ID',
	`user_id` BIGINT(20) NOT NULL  COMMENT '用户ID',
	`order_id` BIGINT(20) NOT NULL  COMMENT '订单ID',
	`goods_id` BIGINT(20) NOT NULL  COMMENT '商品ID',
	PRIMARY KEY(`id`)
)ENGINE = INNODB DEFAULT CHARSET = utf8mb4
COMMENT '秒杀订单表';

-- 添加数据
insert into t_goods values(1,'IPHONE12','IPHONE12 64GB','/img/iphone12.png','IPHONE12 64GB',6399.00,100),
								(2,'IPHONE12 PRO','IPHONE12 PRO 12GB','/img/iphone12pro.png','IPHONE12 PRO 12GB',9299.00,100);
								
insert into t_seckill_goods values(1,1,640,10,'2022-11-11 8:00:00','2022-11-11 9:00:00'),
(2,2,928,10,'2022-11-11 8:00:00','2022-11-11 9:00:00');


select count(*) from t_user;

TRUNCATE table t_user;

-- 创建联合唯一索引
ALTER TABLE `seckill`.`t_seckill_order`
ADD UNIQUE INDEX `seckill_uid_gid`(user_id, goods_id) USING BTREE COMMENT '用户ID+商品ID成为唯一索引,保证一个商品用户只能买一件';

delete from t_user where id not in ('18712501935');

分布式会话(基于redis实现)

实现登录功能–用户登录密码两次MD5加密 (详情见文末最终项目代码)

mybatis-plus代码生成器

所需依赖:

        <!--mybatis-plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>
        <!-- 代码生成器(新)-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.5.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.velocity</groupId>
            <artifactId>velocity-engine-core</artifactId>
            <version>2.0</version>
        </dependency>

代码生成器:

/**
 * 代码生成器
 * https://github.com/baomidou/generator
 */
public class CodeGenerator {

    public static void main(String[] args) {
        String url = "jdbc:mysql://127.0.0.1:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai";
        String userName = "root";
        String passWord = "root";

        String outputDirStr = System.getProperty("user.dir") + "/src/main/java";
        String singletonMapStr = System.getProperty("user.dir") + "/src/main/resources/mapper";
        String parentStr = "com.lkl.demo";

        FastAutoGenerator.create(url, userName, passWord)
                // 全局配置 文件作者名称
                .globalConfig((scanner, builder) ->
                        builder.author("likelong") //设置作者
                                .enableSwagger() //开启swagger
                                .commentDate((LocalDate.now()) + "") //注释日期
                                .fileOverride()//覆盖以生成文件
                                .dateType(DateType.ONLY_DATE) //时间策略 entity 类中使用Date	DateType.ONLY_DATE 默认值: DateType.TIME_PACK
                                .outputDir(outputDirStr) //指定输出目录
                )
                // 包配置
                .packageConfig((scanner, builder) ->
                        builder.parent(parentStr) //设置父包名
                                //  .moduleName("system") // 设置父包模块名
                                .pathInfo(Collections.singletonMap(OutputFile.mapperXml, singletonMapStr)) // 设置mapperXml生成路径
                )
                // 策略配置
                .strategyConfig((scanner, builder) ->
                                builder.addInclude(getTables("t_user"))//设置要生成的表名
                                        .controllerBuilder()
                                        .enableRestStyle()
                                        .enableHyphenStyle()
                                        .entityBuilder()
                                        .enableLombok() //生成Lombok注解
//                                .addTableFills(new Column("create_time", FieldFill.INSERT))
                                        .build()
                                        .mapperBuilder()
                                        .enableBaseResultMap()
                                        .build()
                )
                //生成时不使用表前缀
                .strategyConfig((scanner, builder) -> builder.addTablePrefix("t_").build())
                //模版配置
                .templateConfig(
                        (scanner, builder) ->
                                builder.disable(TemplateType.ENTITY)
                                        .entity("/templates/vm/entity.java")
                                        .service("/templates/vm/service.java")
                                        .serviceImpl("/templates/vm/serviceImpl.java")
                                        .mapper("/templates/vm/mapper.java")
                                        .mapperXml("/templates/vm/mapper.xml")
                                        .controller("/templates/vm/controller.java")
                                        .build()
                )

                /*
                    模板引擎配置,默认 Velocity 可选模板引擎 Beetl 或 Freemarker
                   .templateEngine(new BeetlTemplateEngine())
                   .templateEngine(new FreemarkerTemplateEngine())
                 */
                .execute();
    }

    // 处理 all 情况
    protected static List<String> getTables(String tables) {
        return "all".equals(tables ? Collections.emptyList() : Arrays.asList(tables.split(","));
    }

}

代码生成器详情见git地址

redis + 网关gateway 过滤器 实现单点登录待实现

优化前代码地址

系统压测

JMeter入门

安装

官网:https://jmeter.apache.org/

下载地址:https://jmeter.apache.org/download_jmeter.cgi

下载解压后直接在 bin 目录里双击 jmeter.bat 即可启动(Lunix系统通过 jmeter.sh 启动

修改中文

Options–>Choose Language–>Chinese(Simplified)

image-20220728135535293

简单使用

我们先使用JMeter测试一下跳转商品列表页的接口。

首先创建线程组,步骤:添加–> 线程(用户) --> 线程组

image-20220728135608799

Ramp-up 指在几秒之内启动指定线程数

image-20220728135816088

创建HTTP请求默认值,步骤:添加–> 配置元件 --> HTTP请求默认值

image-20220728135903236

添加测试接口,步骤:添加 --> 取样器 --> HTTP请求

image-20220728135941828

image-20220728141208043

image-20220728141231797

查看输出结果,步骤:添加 --> 监听器 --> 聚合报告/图形结果/用表格察看结果

image-20220728140136452

启动即可在监听器看到对应的结果

image-20220728140211385

自定义变量

准备测试接口

@Controller
@RequestMapping("/user")
@Api(value = "用户表", tags = "用户表")
@Slf4j
public class UserController {

    @Autowired
    private IUserService userService;

    @GetMapping("/info")
    @ResponseBody
    @ApiOperation("返回用户信息")
    public R info(@LoginUser User user) {
        log.info("用户信息:{}", user);
        return R.ok().data("user", user);
    }
}

配置同一用户测试

添加HTTP请求用户信息

image-20220728142104421

查看聚合结果

image-20220728142152030

配置不同用户测试

准备配置文件config.txt

userId和token

18712501935,8c859a8a9ff24058b6ab28d223678d80

添加 --> 配置元件 --> CSV Data Set Config

image-20220728143756756

添加 --> 配置元件 --> HTTP Cookie管理器

image-20220728144024405

修改HTTP请求用户信息

image-20220728144123623

查看结果

image-20220728144238700

正式压测

准备5000个线程,循环10次。压测商品列表接口,测试3次,查看结果。

image-20220728144401604

聚合报告

image-20220728145250250

压测秒杀接口

创建用户

注意:

注释手机号验证注解

使用工具类往数据库插入5000用户,并且调用登录接口获取token,写入config.txt

/**
 * 生成用户工具类
 */
public class UserUtil {

    public static void createUser(int count) throws Exception {
        List<User> users = new ArrayList<>(count);
        //生成用户
        for (int i = 0; i < count; i++) {
            User user = new User();
            user.setId(1233L + i);
            user.setNickname("user" + i);
            user.setSalt("1a2b3c");
            user.setPassword(MD5Util.inputPassToDBPass("123456", user.getSalt()));
            users.add(user);
        }
        System.out.println("create user");
        //插入数据库
        Connection conn = getConn();
        String sql = "insert into t_user(login_count, nickname, register_date, salt, password, id)values(?,?,?,?,?,?)";
        PreparedStatement pstmt = conn.prepareStatement(sql);
        for (int i = 0; i < users.size(); i++) {
            User user = users.get(i);
            pstmt.setInt(1, user.getLoginCount());
            pstmt.setString(2, user.getNickname());
            pstmt.setDate(3, new Date(System.currentTimeMillis()));
            pstmt.setString(4, user.getSalt());
            pstmt.setString(5, user.getPassword());
            pstmt.setLong(6, user.getId());
            pstmt.addBatch();
        }
        pstmt.executeBatch();
        pstmt.close();
        conn.close();
        System.out.println("insert to db");

        //登录,生成token
        String urlString = "http://localhost:8080/user/doLogin";
        File file = new File("C:\\Users\\001\\Desktop\\config.txt");
        if (file.exists()) {
            file.delete();
        }
        RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
        randomAccessFile.seek(0);
        for (int i = 0; i < users.size(); i++) {
            User user = users.get(i);
            URL url = new URL(urlString);
            HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
            httpURLConnection.setRequestMethod("POST");
            httpURLConnection.setDoOutput(true);
            OutputStream outputStream = httpURLConnection.getOutputStream();
            String params = "mobile=" + user.getId() + "&password=123456";
            outputStream.write(params.getBytes());
            outputStream.flush();
            InputStream inputStream = httpURLConnection.getInputStream();
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            byte[] buff = new byte[1024];
            int len = 0;
            while ((len = inputStream.read(buff)) >= 0) {
                byteArrayOutputStream.write(buff, 0, len);
            }
            inputStream.close();
            byteArrayOutputStream.close();
            String response = new String(byteArrayOutputStream.toByteArray());
            ObjectMapper mapper = new ObjectMapper();
            R r = mapper.readValue(response, R.class);
            String token = (String) r.getData().get("token");
            System.out.println("create token:" + user.getId());
            String row = user.getId() + "," + token;
            randomAccessFile.seek(randomAccessFile.length());
            randomAccessFile.write(row.getBytes());
            randomAccessFile.write("\r\n".getBytes());
            System.out.println("write to file :" + user.getId());
        }
        randomAccessFile.close();
        System.out.println();
    }

    private static Connection getConn() throws Exception {
        String url = "jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai";
        String username = "root";
        String password = "root";
        String driver = "com.mysql.cj.jdbc.Driver";
        Class.forName(driver);
        return DriverManager.getConnection(url, username, password);
    }

    public static void main(String[] args) throws Exception {
        createUser(5000);
    }
}

config.txt

image-20220728154913010

配置秒杀接口测试

同样准备5000个线程,循环10次,同上。

请求:

image-20220728155147927

结果:

image-20220728171402603

简单测试,优化前:商品详情列表吞吐量:683,秒杀接口吞吐量:507(吞吐量指系统在单位时间内处理请求的数量

接口慢一点就慢一点,主要是出现了商品超卖现象,商家亏了,砍死开发。

image-20220728171825323

优化开始

页面优化

页面缓存

商品列表页、商品详情页

先去redis里查,查到直接返回,否则,就去查数据库,查到之后存入redis然后再返回。

@Controller
@RequestMapping("/goods")
@Api(value = "商品表", tags = "商品表")
public class GoodsController {

    @Autowired
    private IGoodsService goodsService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private ThymeleafViewResolver thymeleafViewResolver;

    /**
     * windows优化前QPS : 683
     */
    @GetMapping(value = "/toList", produces = "text/html;charset=utf-8")
    @ResponseBody
    public String toList(Model model) {

        //Redis中获取页面,如果不为空,直接返回页面
        String html = (String) redisTemplate.opsForValue().get("goodsList");
        if (!StringUtils.isEmpty(html)) {
            return html;
        }

        List<GoodsVo> goodsVo = goodsService.findGoodsVo();
//        model.addAttribute("user", user);
        model.addAttribute("goodsList", goodsVo);
//        return "goodsList";

        HttpServletRequest request = ServletUtils.getRequest();
        HttpServletResponse response = ServletUtils.getResponse();
        //如果为空,手动渲染,存入Redis并返回
        WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
        html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext);
        if (!org.thymeleaf.util.StringUtils.isEmpty(html)) {
            redisTemplate.opsForValue().set("goodsList", html, 60, TimeUnit.SECONDS);
        }
        return html;
    }

    /**
     * 跳转商品详情
     */
    @GetMapping(value = "/toDetail/{goodsId}", produces = "text/html;charset=utf-8")
    @ResponseBody
    public String toDetail(@LoginUser User user, @PathVariable Long goodsId,
                           Model model) {
        String html = (String) redisTemplate.opsForValue().get("goodsDetail:" + goodsId);
        if (!StringUtils.isEmpty(html)) {
            return html;
        }
        GoodsVo goodsDetail = goodsService.findGoodsVoByGoodsId(goodsId);

        model.addAttribute("user", user);
        model.addAttribute("goods", goodsDetail);

        Date startDate = goodsDetail.getStartDate();
        Date endDate = goodsDetail.getEndDate();
        Date nowDate = new Date();
        //秒杀状态 0 未开始 1 进行中 2 已结束
        int seckillStatus = 0;
        //秒杀倒计时
        int remainSeconds = 0;

        if (nowDate.before(startDate)) {
            //秒杀还未开始 0
            remainSeconds = (int) ((startDate.getTime() - nowDate.getTime()) / 1000);
        } else if (nowDate.after(endDate)) {
            //秒杀已经结束
            seckillStatus = 2;
            remainSeconds = -1;
        } else {
            //秒杀进行中
            seckillStatus = 1;
        }

        model.addAttribute("seckillStatus", seckillStatus);
        model.addAttribute("remainSeconds", remainSeconds);
//        return "goodsDetail";

        HttpServletRequest request = ServletUtils.getRequest();
        HttpServletResponse response = ServletUtils.getResponse();
        //如果为空,手动渲染,存入Redis并返回
        WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
        html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", webContext);
        if (!org.thymeleaf.util.StringUtils.isEmpty(html)) {
            redisTemplate.opsForValue().set("goodsDetail:" + goodsId, html, 60, TimeUnit.SECONDS);
        }
        return html;
    }
}

重启项目,查看商品即可将商品页面数据暂时缓存在redis中,如下

image-20220728204227008

image-20220728204508902

再次压测商品列表接口

本质上吞吐量应该会有所提高,但是这种情况会把整个html页面由后端传给前端,传输数据较大性能也不会有很大提升甚至吞吐量还会降低。

对象缓存

永远将最新的用户信息缓存到redis里,以便控制层给User对象赋值的准确性

LoginUserHandlerResolver总是去redis获取用户信息,赋值给User对象,所以要保证redis里用户信息永远是最新的

/**
 * 有@LoginUser注解的方法参数,注入当前登录用户
 */
@Configuration
public class LoginUserHandlerResolver implements HandlerMethodArgumentResolver {

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().isAssignableFrom(User.class)
                && parameter.hasParameterAnnotation(LoginUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest nativeWebRequest, WebDataBinderFactory factory) {
        String token = CookieUtil.getCookieValue(ServletUtils.getRequest(), "token");
        return redisTemplate.opsForValue().get(Constants.ACCESS_TOKEN + token);
    }

}

IUserService.java

    /**
     * 更新密码
     */
    R updatePassword(String token, String password);

UserServiceImpl

    @Override
    public R updatePassword(String token, String password) {
        // 获取最新用户信息
        User user = (User) redisTemplate.opsForValue().get(Constants.ACCESS_TOKEN + token);
        if (user == null) {
            throw new BusinessException(ResponseEnum.MOBILE_NOT_EXIST);
        }
        user.setPassword(MD5Util.inputPassToDBPass(password, user.getSalt()));

        // 修改数据库信息
        int i = baseMapper.updateById(user);
        if (1 == i) {
            // 更新redis用户信息,保证redis永远都是最新的用户数据
            redisTemplate.opsForValue().set(Constants.ACCESS_TOKEN + token, user, Constants.EXPIRE, TimeUnit.SECONDS);
            return R.ok();
        }

        //  PASSWORD_UPDATE_FAIL(500215, "更新密码失败"),
        return R.error().message(ResponseEnum.PASSWORD_UPDATE_FAIL.getMessage());
    }
商品详情静态化

上面页面缓存,是把整个html页面缓存起来,可以通过缓存提高一定的性能,但是这种情况后端需要把整个html页面传给前端,传输数据较大性能也会受到影响。

下面只把商品详情数据缓存起来并且将商品详情页不变的数据静态化,前端接收后端返回的变化的数据动态渲染变化的数据,提高系统吞吐量。

商品详情实体DetailVo

/**
 * 商品详情实体
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DetailVo {
    private User user;
    private GoodsVo goodsVo;
    private int secKillStatus;
    private int remainSeconds;
}

GoodsController

    /**
     * 跳转商品详情
     */
    @GetMapping("/toDetail/{goodsId}")
    @ResponseBody
    public R toDetail(@LoginUser User user, @PathVariable Long goodsId) {

        DetailVo detail = (DetailVo) redisTemplate.opsForValue().get("goodsDetail:" + goodsId);
        if (detail != null) {
            return R.ok().data("goodsDetail", detail);
        }

        GoodsVo goodsDetail = goodsService.findGoodsVoByGoodsId(goodsId);

        Date startDate = goodsDetail.getStartDate();
        Date endDate = goodsDetail.getEndDate();
        Date nowDate = new Date();
        //秒杀状态 0 未开始 1 进行中 2 已结束
        int seckillStatus = 0;
        //秒杀倒计时
        int remainSeconds = 0;

        if (nowDate.before(startDate)) {
            //秒杀还未开始 0
            remainSeconds = (int) ((startDate.getTime() - nowDate.getTime()) / 1000);
        } else if (nowDate.after(endDate)) {
            //秒杀已经结束
            seckillStatus = 2;
            remainSeconds = -1;
        } else {
            //秒杀进行中
            seckillStatus = 1;
        }

        DetailVo detailVo = new DetailVo();
        detailVo.setGoodsVo(goodsDetail);
        detailVo.setUser(user);
        detailVo.setRemainSeconds(remainSeconds);
        detailVo.setSecKillStatus(seckillStatus);

        redisTemplate.opsForValue().set("goodsDetail:" + goodsId, detailVo, 60, TimeUnit.SECONDS);
        return R.ok().data("goodsDetail", detailVo);
    }

再次运行项目,查看商品详情,就会把商品详情数据暂时缓存在redis中,如下

image-20220729093345496

common.js工具类获取请求行参数

// 获取url参数
function g_getQueryString(name) {
	var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
	var r = window.location.search.substr(1).match(reg);
	if(r != null) return unescape(r[2]);
	return null;
};

商品详情静态化,将商品详情页面放在静态资源目录下,获取后端返回数据动态渲染变化的数据,目录内容如下:(具体详情见文章末尾最终版代码)

image-20220729094749988

秒杀静态化

优化前是等请求处理完,直接跳转页面进行渲染

SeckillController

 @PostMapping("/doSeckill")
    public String doKill(@LoginUser User user, Long goodsId, Model model) {
        if (user == null) {
            return "login";
        }

        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

        if (goodsVo.getStockCount() <= 0) {
            model.addAttribute("errMsg", ResponseEnum.EMPTY_STOCK.getMessage());
            return "secKillFail";
        }

        //判断是否重复抢购 单个用户只能购买同一件商品一次
        SeckillOrder seckillOrder = seckillOrderService.getOne(new
                QueryWrapper<SeckillOrder>()
                .eq("user_id", user.getId())
                .eq("goods_id", goodsVo.getId()));
        if (seckillOrder != null) {
            model.addAttribute("errMsg", ResponseEnum.REPEAT_ERROR.getMessage());
            return "secKillFail";
        }

        Order order = orderService.seckill(user, goodsVo);
        model.addAttribute("order", order);
        model.addAttribute("goods", goodsVo);
        return "orderDetail";
    }

优化后仅返回响应数据

    @PostMapping("/doSeckill")
    @ResponseBody
    public R doKill(@LoginUser User user, Long goodsId) {
        if (user == null) {
            return R.setResult(ResponseEnum.SESSION_ERROR);
        }

        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

        if (goodsVo.getStockCount() <= 0) {
            return R.setResult(ResponseEnum.EMPTY_STOCK);
        }

        //判断是否重复抢购 单个用户只能购买同一件商品一次
        SeckillOrder seckillOrder = seckillOrderService.getOne(new
                QueryWrapper<SeckillOrder>()
                .eq("user_id", user.getId())
                .eq("goods_id", goodsVo.getId()));
        if (seckillOrder != null) {
            return R.setResult(ResponseEnum.REPEAT_ERROR);
        }

        Order order = orderService.seckill(user, goodsVo);
        return R.ok().data("order", order);
    }

goodsDetail.html

image-20220729102212677

    //秒杀方法
    function doSecKill() {
        $.ajax({
            url: 'seckill/doSeckill',
            type: "POST",
            data: {
                goodsId: $('#goodsId').val()
            },
            success: function (data) {
                if (data.code == 0) {
                    window.location.href="/orderDetail.html?orderId="+data.data.order.id;
                } else {
                    layer.msg(data.message);
                }
            }, error: function () {
                layer.msg("客户端请求出错");
            }

        });
    }

application.yml添加以下配置:

spring:
  #静态资源处理
  resources:
    #启动默认静态资源处理,默认启动
    add-mappings: true
    cache:
      cachecontrol:
        #缓存响应时间,单位秒
        max-age: 3600
    chain:
      #资源配链启动缓存,默认启动
      cache: true
      #启动资源链,默认禁用
      enabled: true
      #启用压缩资源(gzip,brotli)解析,默认禁用
      compressed: true
      #启用h5应用缓存,默认禁用
      html-application-cache: true
    static-locations: classpath:/static/
订单详情静态化

根据订单id查询订单详情接口

@RestController
@RequestMapping("/order")
@Api(value = "", tags = "")
public class OrderController {

    @Autowired
    private IOrderService orderService;

    /**
     * 查询订单详情
     * @param orderId 订单id
     */
    @GetMapping("/detail")
    public R detail(Long orderId) {
        OrderDetailVo detail = orderService.detail(orderId);
        return R.ok().data("detail", detail);
    }
}

OrderDetailVo

/**
 * 订单详情实体类
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderDetailVo {

    private Order order;

    private GoodsVo goodsVo;
}
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService {

    @Autowired
    private SeckillGoodsMapper seckillGoodsMapper;

    @Autowired
    private SeckillOrderMapper seckillOrderMapper;

    @Autowired
    private IGoodsService goodsService;

    @Override
    public OrderDetailVo detail(Long orderId) {
        if (null == orderId) {
            throw new BusinessException(ResponseEnum.ORDER_NOT_EXIST);
        }
        Order order = baseMapper.selectById(orderId);
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(order.getGoodsId());
        OrderDetailVo detail = new OrderDetailVo();
        detail.setGoodsVo(goodsVo);
        detail.setOrder(order);
        return detail;
    }
}

orderDetail.html 与商品详情静态化类似

image-20220729105123358

解决库存超卖问题(重要)

第一步:减库存时判断库存是否足够

OrderServiceImpl

        // 秒杀商品表减库存
        SeckillGoods seckillGoods = seckillGoodsMapper.selectOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goodsVo.getId()));
//        seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
//        seckillGoodsMapper.updateById(seckillGoods);

        // 减库存时判断库存是否足够
        boolean res = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
                .setSql("stock_count = " + "stock_count-1")
                .eq("goods_id", goodsVo.getId())
                .gt("stock_count", 0));

解决单个用户只能购买同一件商品一次。可以通过数据库建立唯一索引避免 ⬇

第二步:数据库建立唯一索引
ALTER TABLE `seckill`.`t_seckill_order`
ADD UNIQUE INDEX `seckill_uid_gid`(user_id, goods_id) USING BTREE COMMENT '用户ID+商品ID成为唯一索引,保证一个商品用户只能买一件';

image-20220729112737330

将秒杀订单信息存入Redis,方便判断是否重复抢购时进行查询

OrderServiceImpl

 @Transactional
    @Override
    public Order seckill(User user, GoodsVo goodsVo) {
        // 秒杀商品表减库存
        SeckillGoods seckillGoods = seckillGoodsMapper.selectOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goodsVo.getId()));

        // 减库存时判断库存是否足够
        boolean res = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
                .setSql("stock_count = " + "stock_count-1")
                .eq("goods_id", goodsVo.getId())
                .gt("stock_count", 0));

        if (res) {
            // 生成订单
            Order order = new Order();
            order.setUserId(user.getId());
            order.setGoodsId(goodsVo.getId());
            order.setDeliveryAddrId(0L);
            order.setGoodsName(goodsVo.getGoodsName());
            order.setGoodsCount(1);
            order.setGoodsPrice(seckillGoods.getSeckillPrice());
            order.setOrderChannel(1);
            order.setStatus(0);
            order.setCreateDate(new Date());
            baseMapper.insert(order);

            // 生成秒杀订单
            SeckillOrder seckillOrder = new SeckillOrder();
            seckillOrder.setOrderId(order.getId());
            seckillOrder.setUserId(user.getId());
            seckillOrder.setGoodsId(goodsVo.getId());
            seckillOrderMapper.insert(seckillOrder);
            
            // 将秒杀订单存入redis
            redisTemplate.opsForValue().set("order:" + user.getId() + ":" + goodsVo.getId(), seckillOrder);
            return order;
        } else
            return null;
    }

SeckillController

    @PostMapping("/doSeckill")
    @ResponseBody
    public R doKill(@LoginUser User user, Long goodsId) {
        if (user == null) {
            return R.setResult(ResponseEnum.SESSION_ERROR);
        }

        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

        if (goodsVo.getStockCount() <= 0) {
            return R.setResult(ResponseEnum.EMPTY_STOCK);
        }

        //判断是否重复抢购 单个用户只能购买同一件商品一次
//        SeckillOrder seckillOrder = seckillOrderService.getOne(new
//                QueryWrapper<SeckillOrder>()
//                .eq("user_id", user.getId())
//                .eq("goods_id", goodsVo.getId()));
//        if (seckillOrder != null) {
//            return R.setResult(ResponseEnum.REPEAT_ERROR);
//        }

         //判断是否重复抢购 单个用户只能购买同一件商品一次
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsVo.getId());
        if (seckillOrder != null) {
            return R.setResult(ResponseEnum.REPEAT_ERROR);
        }
        Order order = orderService.seckill(user, goodsVo);
        if (order != null) {
            return R.ok().data("order", order);
        } else {
            return R.error().message("手慢无!");
        }

    }

再次压测,主要看有没有解决商品超卖问题,系统吞吐量提升不明显

image-20220729135911500

优化后,秒杀接口吞吐量:534(有一定提高),主要是解决了商品超卖问题,创建订单数量也是正确的

image-20220729134038247

至此商品超卖问题得到了解决。

服务优化

RabbitMQ入门见我这篇博客

直接进行项目实操

添加依赖:

<!-- AMQP依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>	

application.yml新增配置

spring:
  #RabbitMQ
  rabbitmq:
    #服务器
    host: 47.96.156.51
    #用户名
    username: likelong
    #密码
    password: 247907lkl
    #虚拟主机
    virtual-host: host1
    #端口
    port: 5672
    listener:
      simple:
        #消费者最小数量
        concurrency: 10
        #消费者最大数量
        max-concurrency: 10
        #限制消费者每次只能处理一条消息,处理完在继续下一条消息
        prefetch: 1
        #启动是默认启动容器
        auto-startup: true
        #被拒绝时重新进入队列
        default-requeue-rejected: true
    template:
      retry:
        #发布重试,默认false
        enabled: true
        #重试时间,默认1000ms
        initial-interval: 1000ms
        #重试最大次数,默认3次
        max-attempts: 3
        #最大重试间隔时间
        max-interval: 10000ms
        #重试的间隔乘数,比如配2。0  第一等10s 第二次等20s 第三次等40s
        multiplier: 1
接口优化

思路:减少数据库访问

  1. 系统初始化,把商品库存数量加载到Redis

  2. 收到请求,Redis预减库存。库存不足,直接返回。否则进入第3步

  3. 请求入队,立即返回排队中

  4. 请求出队,生成订单,减少库存

  5. 客户端轮询,是否秒杀成功

RabbitMQ配置类:配置队列和交换机

/**
 * topic模式
 */
@Configuration
public class RabbitMQConfig {

    private static final String QUEUE = "seckillQueue";
    private static final String EXCHANGE = "seckillExchange";

    @Bean
    public Queue queue() {
        return new Queue(QUEUE);
    }

    @Bean
    public TopicExchange topicExchange() {
        return new TopicExchange(EXCHANGE);
    }

    @Bean
    public Binding binding() {
        return BindingBuilder.bind(queue()).to(topicExchange()).with("seckill.#");
    }

}

mq消息发送者

/**
 * 消息发送者
 */
@Service
@Slf4j
public class MQSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 发送秒杀信息
     **/
    public void sendSeckillMessage(String message) {
        log.info("发送消息" + message);
        rabbitTemplate.convertAndSend("seckillExchange", "seckill.message", message);
    }

}

秒杀信息

/**
 * 秒杀信息
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SeckillMessage {

    private User User;

    private Long goodsId;
}

Redis预操作库存

@Controller
@RequestMapping("/seckill")
public class SeckillController implements InitializingBean {

    @Autowired
    private ISeckillOrderService seckillOrderService;

    @Autowired
    private IOrderService orderService;

    @Autowired
    private IGoodsService goodsService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private MQSender mqSender;

    private Map<Long, Boolean> EmptyStockMap = new HashMap<>();
    
        @PostMapping("/doSeckill")
    @ResponseBody
    public R doKill(@LoginUser User user, Long goodsId) {
        if (user == null) {
            return R.setResult(ResponseEnum.SESSION_ERROR);
        }

//        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//
//        if (goodsVo.getStockCount() <= 0) {
//            return R.setResult(ResponseEnum.EMPTY_STOCK);
//        }

        //判断是否重复抢购 单个用户只能购买同一件商品一次
//        SeckillOrder seckillOrder = seckillOrderService.getOne(new
//                QueryWrapper<SeckillOrder>()
//                .eq("user_id", user.getId())
//                .eq("goods_id", goodsVo.getId()));
//        if (seckillOrder != null) {
//            return R.setResult(ResponseEnum.REPEAT_ERROR);
//        }

        //判断是否重复抢购 单个用户只能购买同一件商品一次
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
        if (seckillOrder != null) {
            return R.setResult(ResponseEnum.REPEAT_ERROR);
        }

        //内存标记,减少Redis访问
        if (EmptyStockMap.get(goodsId)) {
            return R.setResult(ResponseEnum.EMPTY_STOCK);
        }

//        Order order = orderService.seckill(user, goodsVo);
//        if (order != null) {
//            return R.ok().data("order", order);
//        } else {
//            return R.error().message("手慢无!");
//        }

        // 预减库存
        Long stock = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
        if (stock.intValue() < 0) {
            EmptyStockMap.put(goodsId, true);
            redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
            return R.setResult(ResponseEnum.EMPTY_STOCK);
        }

        // 请求入队,立即返回排队中
        SeckillMessage message = new SeckillMessage(user, goodsId);
        mqSender.sendSeckillMessage(JSON.toJSONString(message));
        return R.ok();
    }
    
    /**
     * 系统初始化,把商品库存数量加载到Redis
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<GoodsVo> list = goodsService.findGoodsVo();
        if (CollectionUtils.isEmpty(list)) {
            return;
        }
        list.forEach(goodsVo -> {
            redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
            EmptyStockMap.put(goodsVo.getId(), false);
        });
    }
}

mq消息消费者,异步下单操作

/**
 * 消息消费者
 */
@Service
@Slf4j
public class MQReceiver {

    @Autowired
    private IGoodsService goodsService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private IOrderService orderService;

    /**
     * 下单操作
     */
    @RabbitListener(queues = "seckillQueue")
    public void receive(String message) {
        log.info("接收消息:{}", message);

        SeckillMessage seckillMessage = JSON.parseObject(message, SeckillMessage.class);
        User user = seckillMessage.getUser();
        Long goodsId = seckillMessage.getGoodsId();

        // 判断商品数据库库存是否充足
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
        if (goodsVo.getStockCount() < 0) {
            return;
        }

        // 再次判断是否重复抢购
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
        if (seckillOrder != null) {
            return;
        }
        //下单操作
        orderService.seckill(user, goodsVo);
    }
}

客户端轮询查询秒杀结果

SeckillController

    /**
     * 获取秒杀结果
     *  status 订单号,-1:下单失败,0下单成功
     */
    @ApiOperation("获取秒杀结果")
    @GetMapping("/getResult")
    @ResponseBody
    public R getResult(@LoginUser User user, Long goodsId) {
        if (user == null) {
            return R.setResult(SESSION_ERROR);
        }
        Long status = seckillOrderService.getResult(user, goodsId);
        return R.ok().data("status", status);
    }

SeckillOrderServiceImpl

@Service
public class SeckillOrderServiceImpl extends ServiceImpl<SeckillOrderMapper, SeckillOrder> implements ISeckillOrderService {

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public Long getResult(User user, Long goodsId) {
        QueryWrapper<SeckillOrder> wrapper = new QueryWrapper<>();
        wrapper.eq("user_id", user.getId())
                .eq("goods_id", goodsId);

        SeckillOrder seckillOrder = baseMapper.selectOne(wrapper);

        if (seckillOrder != null) {
            return seckillOrder.getOrderId();
        } else if (redisTemplate.hasKey("isStockEmpty:" + goodsId)) {
            return -1L;
        } else {
            return 0L;
        }

    }
}

下单方法做相应调整

 @Transactional
    @Override
    public Order seckill(User user, GoodsVo goodsVo) {
        // 秒杀商品表减库存
        SeckillGoods seckillGoods = seckillGoodsMapper.selectOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goodsVo.getId()));
        seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
//        seckillGoodsMapper.updateById(seckillGoods);

        if (seckillGoods.getStockCount() < 0) {
            //判断是否还有库存
            redisTemplate.opsForValue().set("isStockEmpty:" + goodsVo.getId(), true);
            return null;
        }

        // 减库存时判断库存是否足够
        boolean res = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
                .setSql("stock_count = " + "stock_count-1")
                .eq("goods_id", goodsVo.getId())
                .gt("stock_count", 0));

        if (res) {
            // 生成订单
            Order order = new Order();
            order.setUserId(user.getId());
            order.setGoodsId(goodsVo.getId());
            order.setDeliveryAddrId(0L);
            order.setGoodsName(goodsVo.getGoodsName());
            order.setGoodsCount(1);
            order.setGoodsPrice(seckillGoods.getSeckillPrice());
            order.setOrderChannel(1);
            order.setStatus(0);
            order.setCreateDate(new Date());
            baseMapper.insert(order);

            // 生成秒杀订单
            SeckillOrder seckillOrder = new SeckillOrder();
            seckillOrder.setOrderId(order.getId());
            seckillOrder.setUserId(user.getId());
            seckillOrder.setGoodsId(goodsVo.getId());
            seckillOrderMapper.insert(seckillOrder);
            redisTemplate.opsForValue().set("order:" + user.getId() + ":" + goodsVo.getId(), seckillOrder);
            return order;
        } else
            return null;
    }

goodsDetail.htm

 //秒杀方法
 function doSecKill() {
        $.ajax({
            url: 'seckill/doSeckill',
            type: "POST",
            data: {
                goodsId: $('#goodsId').val()
            },
            success: function (data) {
                if (data.code == 0) {
                    // window.location.href="/orderDetail.html?orderId="+data.data.order.id;
                    getResult($("#goodsId").val());
                } else {
                    layer.msg(data.message);
                }
            }, error: function () {
                layer.msg("客户端请求出错");
            }

        });
    }
    
      function getResult(goodsId) {
        g_showLoading();
        $.ajax({
            url: "/seckill/getResult",
            type: "GET",
            data: {
                goodsId: goodsId
            },
            success: function (data) {
                if (data.code == 0) {
                    var result = data.data.status;
                    if (result < 0) {
                        layer.msg("对不起,秒杀失败");
                    } else if (result == 0) {
                        setTimeout(function () {
                            getResult(goodsId)
                        });
                    } else {
                        layer.confirm("恭喜您,秒杀成功!查看订单?", {btn: ["确定", "取消"]},
                            function () {
                                window.location.href = "/orderDetail.html?orderId=" + result;
                            },
                            function () {
                                layer.close();
                            }
                        )
                    }
                }
            },
            error: function () {
                layer.msg("客户端请求错误");
            }
        });
    }

再次启动项目,redis预加载商品库存

image-20220729172915585

秒杀成功,数据库以及redis商品数量均正确。

再次压测秒杀接口,吞吐量又有一定的提升

image-20220729181143479

数据库以及Redis库存数量和订单都正确


优化Redis操作库存

上面代码实际演示会发现Redis的库存有问题,原因在于Redis没有做到原子性。我们采用锁去解决

分布式锁

进来一个线程先占位,当别的线程进来操作时,发现已经有人占位了,就会放弃或者稍后再试线程操作执行完成后,需要调用del指令释放位子。

    @Test
    public void testLock1() {
        ValueOperations valueOperations = redisTemplate.opsForValue();

        //占位 key不存在,设置成功返回true;否则设置失败返回false
        Boolean isLock = valueOperations.setIfAbsent("k1", "v1");

        // 如果占位成功,进行正常操作
        if (isLock) {
            valueOperations.set("name", "XXX");
            String name = (String) valueOperations.get("name");
            System.out.println("name = " + name);
            // 操作结束,删除锁
            redisTemplate.delete("k1");
        } else {
            System.out.println("有线程在使用,请稍后再试。。。");
        }
    }

为了防止业务执行过程中抛异常或者挂机导致del指定没法调用形成死锁,可以添加锁的超时时间

    @Test
    public void testLock2() {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        
        //给锁设置一个过期时间,防止应用在运行过程中抛出异常导致锁无法正常释放
        Boolean isLock = valueOperations.setIfAbsent("k1", "v1", 5, TimeUnit.SECONDS);

        // 如果占位成功,进行正常操作
        if (isLock) {
            valueOperations.set("name", "XXX");
            String name = (String) valueOperations.get("name");
            System.out.println("name = " + name);
            // 操作结束,删除锁
            redisTemplate.delete("k1");
        } else {
            System.out.println("有线程在使用,请稍后再试。。。");
        }
    }

上面例子,如果业务非常耗时会紊乱。举例:第一个线程首先获得锁,然后执行业务代码,但是业务代码耗时8秒,这样会在第一个线程的任务还未执行成功锁就会被释放,这时第二个线程会获取到锁开始执行,在第二个线程开执行了3秒,第一个线程也执行完了,此时第一个线程会释放锁,但是注意,他释放的第二个现成的锁,释放之后,第三个线程进来。这种情况可能会造成错误释放锁的情况。

解决方案:

尽量避免在获取锁之后,执行耗时操作将锁的value设置为一个随机字符串,每次释放锁的时候,都去比较随机字符串是否一致,如果一致,再去释放,否则不释放。

释放锁时要去查看所对应的value,比较value是否正确,释放锁总共三个步骤,这三个步骤不具备原子性。这时候就用到lua脚本了。

Lua脚本

Lua脚本优势:

  • 使用方便,Redis内置了对Lua脚本的支持
  • Lua脚本可以在Rdis服务端原子地执行多个Redis命令
  • 由于网络在很大程度上会影响到Redis性能,使用Lua脚本可以让多个命令一次执行,可以有效解决网络给Redis带来的性能问题

使用Lua脚本思路:

  • 提前在Redis服务端写好Lua脚本,然后在java客户端去调用脚本
  • 可以在java客户端写Lua脚本,写好之后,去执行。需要执行时,每次将脚本发送到Redis上去执行

创建Lua脚本(放在resources目录下)

if redis.call("get",KEYS[1])==ARGV[1] then
     return redis.call("del",KEYS[1])
else
     return 0
end

调用脚本

RedisConfig

    @Bean
    public DefaultRedisScript<Boolean> script() {
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
        //lock.lua脚本位置和application.yml同级目录
        redisScript.setLocation(new ClassPathResource("lock.lua"));
        redisScript.setResultType(Boolean.class);
        return redisScript;
    }

测试

    @Test
    public void testLock3() {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        String value = UUID.randomUUID().toString();
        Boolean isLock = valueOperations.setIfAbsent("k1", value, 5, TimeUnit.SECONDS);
        if (isLock) {
            valueOperations.set("name", "xxx");
            String name = (String) valueOperations.get("name");
            System.out.println("name=" + name);
            System.out.println(valueOperations.get("k1"));
            //操作结束,删除锁
            Boolean result = (Boolean) redisTemplate.execute(redisScript, Collections.singletonList("k1"), value);
            System.out.println(result);
        } else {
            System.out.println("有线程在使用,请稍后再试");
        }
    }

优化Redis预减库存

stock.lua

if(redis.call('exists',KEYS[1])==1) then
    local stock =tonumber(redis.call('get',KEYS[1]));
    if(stock>0) then
        redis.call('incrby',KEYS[1],-1);
        return stock;
    end;
        return 0;
end;

配置lua脚本,RedisConfig

@Bean
public DefaultRedisScript<Long> script() {
   DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
   //放在和application.yml 同层目录下
   redisScript.setLocation(new ClassPathResource("stock.lua"));
   redisScript.setResultType(Long.class);
   return redisScript; 
}

SeckillController.java

   @PostMapping("/doSeckill")
   @ResponseBody
   public R doKill(@LoginUser User user, Long goodsId) {
       if (user == null) {
           return R.setResult(SESSION_ERROR);
       }

//        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//
//        if (goodsVo.getStockCount() <= 0) {
//            return R.setResult(ResponseEnum.EMPTY_STOCK);
//        }

       //判断是否重复抢购 单个用户只能购买同一件商品一次
//        SeckillOrder seckillOrder = seckillOrderService.getOne(new
//                QueryWrapper<SeckillOrder>()
//                .eq("user_id", user.getId())
//                .eq("goods_id", goodsVo.getId()));
//        if (seckillOrder != null) {
//            return R.setResult(ResponseEnum.REPEAT_ERROR);
//        }

       //判断是否重复抢购 单个用户只能购买同一件商品一次
       SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
       if (seckillOrder != null) {
           return R.setResult(ResponseEnum.REPEAT_ERROR);
       }

       //内存标记,减少Redis访问
       if (EmptyStockMap.get(goodsId)) {
           return R.setResult(ResponseEnum.EMPTY_STOCK);
       }

//        Order order = orderService.seckill(user, goodsVo);
//        if (order != null) {
//            return R.ok().data("order", order);
//        } else {
//            return R.error().message("手慢无!");
//        }

       // 预减库存
//        Long stock = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
//        if (stock.intValue() < 0) {
//            EmptyStockMap.put(goodsId, true);
//            redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
//            return R.setResult(ResponseEnum.EMPTY_STOCK);
//        }

       // 预减库存
       Long stock = (Long) redisTemplate.execute(redisScript, Collections.singletonList("seckillGoods:" + goodsId), Collections.EMPTY_LIST);
       if (stock == 0) {
           EmptyStockMap.put(goodsId, true);
           return R.setResult(ResponseEnum.EMPTY_STOCK);
       }

       // 请求入队,立即返回排队中
       SeckillMessage message = new SeckillMessage(user, goodsId);
       mqSender.sendSeckillMessage(JSON.toJSONString(message));
       return R.ok();
   }

安全优化

秒杀接口地址隐藏

秒杀开始之前,先去请求接口获取秒杀地址

ISeckillOrderService

    /**
     * 生成秒杀接口
     */
    String createPath(User user, Long goodsId);

    /**
     * 验证秒杀地址
     */
    boolean checkPath(User user, Long goodsId, String path);

SeckillOrderServiceImpl

    @Override
    public String createPath(User user, Long goodsId) {
        String str = MD5Util.md5(UUIDUtil.uuid() + "123456");

        redisTemplate.opsForValue().set("seckillPath:" + user.getId() + ":" +
                goodsId, str, 60, TimeUnit.SECONDS);
        return str;
    }

    @Override
    public boolean checkPath(User user, Long goodsId, String path) {
        if (user == null || StringUtils.isEmpty(path)) {
            return false;
        }
        String redisPath = (String) redisTemplate.opsForValue().get("seckillPath:" +
                user.getId() + ":" + goodsId);
        return path.equals(redisPath);

    }

SeckillController 秒杀前获取秒杀地址,秒杀时校验地址

    /**
     * 获取秒杀地址
     */
    @GetMapping("/path")
    public R getPath(@LoginUser User user, Long goodsId) {
        if (user == null) {
            return R.setResult(SESSION_ERROR);
        }

        String path = seckillOrderService.createPath(user, goodsId);
        return R.ok().data("path", path);
    }

     @PostMapping("/{path}/doSeckill")
    @ResponseBody
    public R doKill(@LoginUser User user, Long goodsId, @PathVariable String path) {
        if (user == null) {
            return R.setResult(SESSION_ERROR);
        }

        boolean checkPath = seckillOrderService.checkPath(user, goodsId, path);
        if (!checkPath) {
            return R.setResult(ResponseEnum.REQUEST_ILLEGAL);
        }

        //判断是否重复抢购 单个用户只能购买同一件商品一次
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
        if (seckillOrder != null) {
            return R.setResult(ResponseEnum.REPEAT_ERROR);
        }

        //内存标记,减少Redis访问
        if (EmptyStockMap.get(goodsId)) {
            return R.setResult(ResponseEnum.EMPTY_STOCK);
        }

        // 预减库存
        Long stock = (Long) redisTemplate.execute(redisScript, Collections.singletonList("seckillGoods:" + goodsId), Collections.EMPTY_LIST);
        if (stock == 0) {
            EmptyStockMap.put(goodsId, true);
            return R.setResult(ResponseEnum.EMPTY_STOCK);
        }

        // 请求入队,立即返回排队中
        SeckillMessage message = new SeckillMessage(user, goodsId);
        mqSender.sendSeckillMessage(JSON.toJSONString(message));
        return R.ok();
    }

goodsDetail.htm

       <button class="btn btn-primary" type="submit" id="buyButton"
              onclick="getSeckillPath()">立即秒杀
              <input type="hidden" name="goodsId" id="goodsId">
       </button>

    function getSeckillPath() {
        var goodsId = $("#goodsId").val();
        g_showLoading();
        $.ajax({
            url: "/seckill/path",
            type: "GET",
            data: {
                goodsId: goodsId
            },
            success: function (data) {
                if (data.code == 0) {
                    var path = data.data.path;
                    doSecKill(path);
                } else {
                    layer.msg(data.message);
                }
            },
            error: function () {
                layer.msg("客户端请求错误");
            }
        });
    }

    //秒杀方法
    function doSecKill(path) {
        $.ajax({
            url: `seckill/${path}/doSeckill`,
            type: "POST",
            data: {
                goodsId: $('#goodsId').val()
            },
            success: function (data) {
                if (data.code == 0) {
                    // window.location.href="/orderDetail.html?orderId="+data.data.order.id;
                    getResult($("#goodsId").val());
                } else {
                    layer.msg(data.message);
                }
            }, error: function () {
                layer.msg("客户端请求出错");
            }

        });
    }

再次点击秒杀,会有两次请求,先请求获取地址,再拿着地址拼接成秒杀地址进行秒杀,整个流程也不会有问题。

111

图形验证码

点击秒杀开始前,先输入验证码,分散用户的请求

生成验证码

引入依赖

	<!-- 验证码 -->
    <dependency>
        <groupId>com.github.whvcse</groupId>
        <artifactId>easy-captcha</artifactId>
        <version>1.6.2</version>
    </dependency>

生成验证码

    /**
     * 验证码
     */
    @GetMapping("/captcha")
    public void verifyCode(@LoginUser User user, Long goodsId, HttpServletResponse response) {
        if (null == user || goodsId < 0) {
            throw new BusinessException(ResponseEnum.REQUEST_ILLEGAL);
        }
        // 设置请求头为输出图片类型
        response.setContentType("image/jpg");
        response.setHeader("Pragma", "No-cache");
        response.setHeader("Cache-Control", "no-cache");
        response.setDateHeader("Expires", 0);
        //生成验证码,将结果放入redis
        ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 32, 3);

        redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId, captcha.text
                (), 300, TimeUnit.SECONDS);
        try {
            captcha.out(response.getOutputStream());
        } catch (IOException e) {
            log.error("验证码生成失败,{}", e.getMessage());
        }
    }

获取拼接地址时,校验验证码

    /**
     * 获取秒杀地址
     */
    @GetMapping("/path")
    @ResponseBody
    public R getPath(@LoginUser User user, Long goodsId, String captcha) {
        if (user == null) {
            return R.setResult(SESSION_ERROR);
        }

        boolean checkCaptcha = seckillOrderService.checkCaptcha(user, goodsId, captcha);
        if (!checkCaptcha) {
            return R.setResult(ResponseEnum.ERROR_CAPTCHA);
        }

        String path = seckillOrderService.createPath(user, goodsId);
        return R.ok().data("path", path);
    }

goodsDetail.html

                <div class="row">
                    <div class="form-inline">
                        <img id="captchaImg" width="130" height="32" onclick="refreshCaptcha()" style="display: none">
                        <input id="captcha" class="form-control" style="display: none">
                        <button class="btn btn-primary" type="submit" id="buyButton"
                                onclick="getSeckillPath()">立即秒杀
                            <input type="hidden" name="goodsId" id="goodsId">
                        </button>
                    </div>
                </div>


function refreshCaptcha() {
        $("#captchaImg").attr("src", "/seckill/captcha?goodsId=" + $("#goodsId").val() + "&time=" + new Date());
    }

    function getSeckillPath() {
        var goodsId = $("#goodsId").val();
        var captcha = $('#captcha').val();
        g_showLoading();
        $.ajax({
            url: "/seckill/path",
            type: "GET",
            data: {
                goodsId: goodsId,
                captcha: captcha
            },
            success: function (data) {
                if (data.code == 0) {
                    var path = data.data.path;
                    doSecKill(path);
                } else {
                    layer.msg(data.message);
                }
            },
            error: function () {
                layer.msg("客户端请求错误");
            }
        });
    }

    //秒杀方法
    function doSecKill(path) {
        $.ajax({
            url: `seckill/${path}/doSeckill`,
            type: "POST",
            data: {
                goodsId: $('#goodsId').val()
            },
            success: function (data) {
                if (data.code == 0) {
                    // window.location.href="/orderDetail.html?orderId="+data.data.order.id;
                    getResult($("#goodsId").val());
                } else {
                    layer.msg(data.message);
                }
            }, error: function () {
                layer.msg("客户端请求出错");
            }

        });
    }

测试开始:

输入错误验证码提示错误并且无法秒杀

输入正确验证码,正常秒杀

image-20220731155046952

接口限流
简单接口限流
   /**
     * 获取秒杀地址
     */
    @GetMapping("/path")
    @ResponseBody
    public R getPath(@LoginUser User user, Long goodsId, String captcha) {
        if (user == null) {
            return R.setResult(SESSION_ERROR);
        }

        ValueOperations valueOperations = redisTemplate.opsForValue();

        //限制访问次数,5秒内访问5次
        String uri = ServletUtils.getRequest().getRequestURI();
        //方便测试
        captcha = "0";
        Integer count = (Integer) valueOperations.get(uri + ":" + user.getId());
        if (count == null) {
            valueOperations.set(uri + ":" + user.getId(), 1, 5, TimeUnit.SECONDS);
        } else if (count < 5) {
            valueOperations.increment(uri + ":" + user.getId());
        } else {
            return R.setResult(ResponseEnum.ACCESS_LIMIT_REACHED);
        }

        boolean checkCaptcha = seckillOrderService.checkCaptcha(user, goodsId, captcha);
        if (!checkCaptcha) {
            return R.setResult(ResponseEnum.ERROR_CAPTCHA);
        }

        String path = seckillOrderService.createPath(user, goodsId);
        return R.ok().data("path", path);
    }

测试

image-20220731171010415

通用接口限流(注解+拦截器)

注解

/**
 * 接口限流注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLimit {
   
    /**
     * 多少秒
     */
    int second();

    /**
     * 最大访问次数
     */
    int maxCount();

    /**
     * 是否需要登录
     */
    boolean needLogin() default true;
}

拦截器

@Component
public class AccessLimitInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        User user = getUser();
        UserContext.setUser(user);
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            AccessLimit accessLimit = handlerMethod.getMethodAnnotation(AccessLimit.class);
            if (accessLimit == null) {
                return true;
            }

            int second = accessLimit.second();
            int maxCount = accessLimit.maxCount();
            boolean needLogin = accessLimit.needLogin();
            String requestURI = request.getRequestURI();
            if (needLogin) {
                if (user == null) {
                    render(response, ResponseEnum.SESSION_ERROR);
                    return false;
                }
                requestURI += ":" + user.getId();
            }
            ValueOperations valueOperations = redisTemplate.opsForValue();
            Integer count = (Integer) valueOperations.get(requestURI);
            if (count == null) {
                valueOperations.set(requestURI, 1, second, TimeUnit.SECONDS);
            } else if (count < maxCount) {
                valueOperations.increment(requestURI);
            } else {
                render(response, ResponseEnum.ACCESS_LIMIT_REACHED);
                return false;
            }
        }
        return true;
    }
    
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        UserContext.removeUser();
    }

    /**
     * 构建返回对象
     */
    private void render(HttpServletResponse response, ResponseEnum responseEnum) throws IOException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json");
        PrintWriter printWriter = response.getWriter();
        R r = R.setResult(responseEnum);
        printWriter.write(new ObjectMapper().writeValueAsString(r));
        printWriter.flush();
        printWriter.close();
    }

    private User getUser() {
        String token = CookieUtil.getCookieValue(ServletUtils.getRequest(), "token");
        if (token == null)
            return null;
        else
            return (User) redisTemplate.opsForValue().get(Constants.ACCESS_TOKEN + token);
    }
}

UserContext.java

/**
 * 保证线程数据隔离,不同线程用户信息都是它当前线程的用户
 */
public class UserContext {

    private static ThreadLocal<User> userHolder = new ThreadLocal<>();

    public static void setUser(User user) {
        userHolder.set(user);
    }

    public static User getUser() {
        return userHolder.get();
    }

    public static void removeUser() {
        userHolder.remove();
    }
}

LoginUserHandlerResolver.java代码也有所调整

/**
 * 有@LoginUser注解的方法参数,注入当前登录用户
 */
@Configuration
public class LoginUserHandlerResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().isAssignableFrom(User.class)
                && parameter.hasParameterAnnotation(LoginUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest nativeWebRequest, WebDataBinderFactory factory) {
        return UserContext.getUser();
    }

}

添加拦截器

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private LoginUserHandlerResolver loginUserHandlerResolver;

    @Autowired
    private AccessLimitInterceptor accessLimitInterceptor;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserHandlerResolver);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(accessLimitInterceptor);
    }
}

最后使用,只需在方法上添加注解即可实现接口限流,如下:

  /**
     * 获取秒杀地址
     */
    @GetMapping("/path")
    @ResponseBody
    @AccessLimit(second = 5, maxCount = 5)
    public R getPath(@LoginUser User user, Long goodsId, String captcha) {
        if (user == null) {
            return R.setResult(SESSION_ERROR);
        }

        if (StringUtils.isEmpty(captcha)) {
            return R.setResult(ResponseEnum.CAPTCHA_NULL_ERROR);
        }

        boolean checkCaptcha = seckillOrderService.checkCaptcha(user, goodsId, captcha);
        if (!checkCaptcha) {
            return R.setResult(ResponseEnum.ERROR_CAPTCHA);
        }

        String path = seckillOrderService.createPath(user, goodsId);
        return R.ok().data("path", path);
    }

测试效果一样,并且这种方式更加通用

image-20220731171010415

项目最终版代码地址

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
好的,这里是一个简单的秒杀系统demo,使用Java语言和Spring框架实现: 1. 数据库表设计 首先,我们需要设计数据库表来存储商品信息和订单信息。假设我们的商品表名为goods,包含以下字段: - id:商品ID - name:商品名称 - price:商品价格 - stock:商品库存 - start_time:秒杀开始时间 - end_time:秒杀结束时间 订单表名为orders,包含以下字段: - id:订单ID - user_id:用户ID - goods_id:商品ID - status:订单状态(0:未支付,1:已支付) - create_time:订单创建时间 2. 商品列表页面和秒杀按钮 在前端页面上展示商品列表,每个商品下方有一个秒杀按钮。点击秒杀按钮后,通过AJAX请求后台接口进行秒杀操作。 3. 后台接口实现 (1)获取商品信息接口 在后台实现一个接口,根据商品ID获取商品信息。代码如下: ```java @GetMapping("/goods/{id}") public Goods getGoodsById(@PathVariable("id") long id) { return goodsService.getGoodsById(id); } ``` (2)秒杀操作接口 在后台实现一个接口,处理秒杀操作。在秒杀开始前,先检查商品库存是否足够,如果足够,则减少库存,创建订单。代码如下: ```java @PostMapping("/seckill") public Result seckill(@RequestParam("userId") long userId, @RequestParam("goodsId") long goodsId) { // 检查商品库存是否足够 Goods goods = goodsService.getGoodsById(goodsId); if (goods.getStock() < 1) { return Result.error(CodeMsg.SECKILL_OVER); } // 减少库存,创建订单 goodsService.reduceStock(goods); Order order = orderService.createOrder(userId, goods); return Result.success(order); } ``` 4. 并发优化 为了防止并发访问导致库存错误,我们需要进行并发优化。可以使用redis作为缓存,限制每个用户只能秒杀一次,保证每个商品每次只能被一个用户秒杀。 以上就是一个简单的秒杀系统demo,可以根据实际需求进行调整和扩展。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不进大厂不改名二号

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值