第三章--美食社交APP--秒杀服务(重点)

1. 需求说明

image.png

1.1 说明:

  • 设置抢购活动,比如活动对应代金券,开始时间,结束时间,秒杀券数量等
  • 定时开始抢购活动,禁止超卖
  • 用户抢购限制, 一个用户只能购买一单

1.2 表结构设计

代金券表
抢购活动表
订单表

2. 解决方案

秒杀场景特点 : 大量用户同时抢购 ; 请求数量远大于商品库存量, 只有少数客户可以抢购成功; 业务流程不复杂,核心功能是下订单。
秒杀场景从以下几方面进行应对 :

  1. 限流
  2. 缓存
  3. 异步
  4. 分流

3. 创建秒杀服务 fs_seckill

3.1 添加pom依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>food_social</artifactId>
        <groupId>com.itkaka</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>fs_seckill</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <!-- eureka client -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!-- spring web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- mybatis -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        <!-- mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!-- spring data redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- commons -->
        <dependency>
            <groupId>com.itkaka</groupId>
            <artifactId>fs_commons</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <!-- redisson 依赖 -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.16.1</version>
        </dependency>
    </dependencies>

    <!-- 集中定义项目所需插件 -->
    <build>
        <plugins>
            <!-- spring boot maven 项目打包插件 -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

3.2 配置文件

server:
  port: 8093 # 端口

spring:
  application:
    name: fs_seckill # 应用名
  # 数据库
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/db_lezijie_food_social?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false
  # Redis
#  redis:
#    port: 6379
#    host: 192.168.10.101
#    timeout: 3000
#    password: 123456
#    database: 5
  # swagger
  swagger:
    base-package: com.itkaka.seckill
    title: 美食社交API接口文档

# 配置 Eureka Server 注册中心
eureka:
  instance:
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
  client:
    service-url:
      defaultZone: http://localhost:8090/eureka/

# Mybatis
mybatis:
  configuration:
    map-underscore-to-camel-case: true # 开启驼峰映射

# oauth2 服务地址
service:
  name:
    fs-oauth-server: http://fs_oauth/

# 配置日志
logging:
  pattern:
    console: '%d{2100-01-01 13:14:00.666} [%thread] %-5level %logger{50} - %msg%n'

4. 代码实现

# token 有效时间,单位秒
token-validity-time: 2592000
refresh-token-validity-time: 2592000
# token 这里将授权认证中心的令牌失效时间修改成为了一个月, 可按照需求修改

4.1 相关实体类

4.1.1 抢购代金券活动表
package com.itkaka.seckill.model.pojo;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.itkaka.commons.model.base.BaseModel;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import org.springframework.format.annotation.DateTimeFormat;

import java.util.Date;

@Getter
@Setter
@ApiModel(description = "抢购代金券信息")
public class SeckillVouchers extends BaseModel {

    @ApiModelProperty("代金券外键")
    private Integer fkVoucherId;
    @ApiModelProperty("数量")
    private int amount;
    @ApiModelProperty("抢购开始时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "GMT+8")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
    private Date startTime;
    @ApiModelProperty("抢购结束时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "GMT+8")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
    private Date endTime;

}

4.1.2 代金券订单表
package com.itkaka.seckill.model.pojo;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;

@ApiModel(description = "代金券订单信息")
@Getter
@Setter
public class VoucherOrders {

    @ApiModelProperty("订单编号")
    private String orderNo;
    @ApiModelProperty("代金券")
    private Integer fkVoucherId;
    @ApiModelProperty("下单用户")
    private Integer fkDinerId;
    @ApiModelProperty("生成qrcode")
    private String qrcode;
    @ApiModelProperty("支付方式 0=微信支付 1=支付宝")
    private int payment;
    @ApiModelProperty("订单状态 -1=已取消 0=未支付 1=已支付 2=已消费 3=已过期")
    private int status;
    @ApiModelProperty("订单类型 0=正常订单 1=抢购订单")
    private int orderType;
    @ApiModelProperty("抢购订单的外键")
    private int fkSeckillId;

}

4.2 相关配置类

4.2.1 Rest配置类
package com.itkaka.seckill.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;

import java.util.Collections;

@Configuration
public class RestTemplateConfiguration {

    public RestTemplate restTemplate(){
        RestTemplate restTemplate = new RestTemplate();

        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        converter.setSupportedMediaTypes(Collections.singletonList(MediaType.TEXT_PLAIN));
        restTemplate.getMessageConverters().add(converter);

        return restTemplate;
    }

}

4.3 全局异常处理

package com.itkaka.seckill.handler;

import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;

@RestControllerAdvice // 将输出的内容写入 ResponseBody 中
@Slf4j
public class GlobalExceptionHandler {
    @Resource
    private HttpServletRequest request;

    @ExceptionHandler(ParameterException.class)
    public ResultInfo<Map<String,String>> handlerParameterException(ParameterException ex){
        String path = request.getRequestURI();
        ResultInfo<Map<String,String>> resultInfo =
                ResultInfoUtil.buildError(ex.getErrorCode(),ex.getMessage(),path);
        return resultInfo;
    }

    @ExceptionHandler(Exception.class)
    public ResultInfo<Map<String,String>> handlerException(Exception e){
        log.info("未知异常:{}",e);
        String path = request.getRequestURI();
        ResultInfo<Map<String,String>> resultInfo = ResultInfoUtil.buildError(path);
        return resultInfo;
    }

}

4.4 添加秒杀活动

  1. 非空校验
  2. 活动时间校验
  3. 验证数据库是否已经存在该券的秒杀活动
  4. 插入数据库在 ms-gateway 网关中放行,此接口为平台后台调用,不需要食客登录
4.4.1 PostMan 测试

访问:http://localhost:8093/add

4.5 客户端秒杀

  1. 基本参数校验
  2. 判断此代金券是否加入抢购
  3. 是否有效
  4. 是否开始 、 结束
  5. 是否卖完
  6. 登录用户信息
  7. 判断登录用户是都已抢到 (一个用户一次活动只能买一次)
  8. 扣库存
  9. 下单

5. 压力测试

Windows环境下使用JMeter5.3模拟抢购场景

5.1 下载Jmeter5.0工具

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

5.2 解压启动

在解压目录下的bin目录下找到jemeter.bat,这是window启动脚本,双击启动

5.3 生成登录token

导入注册diners数据
数据库运行 init_diners_data.sql 文件。
在 ms-oauth2-server 项目中编写测试用例。
修改OAuth2ServerApplicationTests代码,添加mock测试客户端、

package com.itkaka.oauth2;

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import javax.annotation.Resource;
@SpringBootTest
@AutoConfigureMockMvc
public class OAuth2ServerApplicationTests {
  @Resource
  protected MockMvc mockMvc;
}

创建OAuthControllerTests生成token,文件存在根目录下

package com.itkaka.oauth2.controller;

import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.oauth2.OAuth2ServerApplicationTests;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.util.Base64Utils;
import java.nio.file.Files;
import java.nio.file.Paths;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
public class OAuthControllerTest extends OAuth2ServerApplicationTests {
  @Test
  public void writeToken() throws Exception {
    String authorization = Base64Utils.encodeToString("appId:123456".getBytes());
    StringBuffer tokens = new StringBuffer();
    for (int i = 0; i < 2000; i++) {
      MvcResult mvcResult = super.mockMvc.perform(MockMvcRequestBuilders.post("/oauth/token")
         .header("Authorization", "Basic " + authorization)
         .contentType(MediaType.APPLICATION_FORM_URLENCODED)
         .param("username", "test" + i)
         .param("password", "123456")
         .param("grant_type", "password")
         .param("scope", "api")
     )
         .andExpect(status().isOk())
          // .andDo(print())
         .andReturn();
      String contentAsString = mvcResult.getResponse().getContentAsString();
      ResultInfo resultInfo = (ResultInfo)JSONUtil.toBean(contentAsString, ResultInfo.class);
      JSONObject result = (JSONObject) resultInfo.getData();
      String token = result.getStr("accessToken");
      tokens.append(token).append("\r\n");
   }
    Files.write(Paths.get("tokens.txt"), tokens.toString().getBytes());
 }
}

5.4 导入测试计划

打开文件 线程组.jmx 。
1)多人抢购代金券:模拟5000个并发,2000个账号进行抢购
结果:数据库中t_seckill_vouchers表的amount会为负数(超卖了),t_vouchers_orders的订单会
超过100,说明卖多了
image.png
image.png
image.png
2)模拟某个用户多次抢购:模拟10000个并发,1个账号进行抢购
image.png
结果后台会报错,同时订单表会出现针对一个voucher一个用户多个订单
先 TRUNCATE t_voucher_orders 清空数据,修改 t_seckill_vouchers amount 为 100。
image.png

6. Redis 防止超卖

6.1 解决思路

将活动写入Redis中,通过Redis自减指令扣除库存
修改添加活动的业务
在SeckillVoucherService中,修改addSeckillVouchers()方法将数据存入Redis中,以HashMap的方式存储
伪代码

@Transactional(rollbackFor = Exception.class)
public void addSeckillVouchers(SeckillVouchers seckillVouchers) {
 
  String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + seckillVouchers.getFkVoucherId();
 
  // 验证 Redis 中是否已经存在该券的秒杀活动
  Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);
  AssertUtil.isTrue(!seckillVoucherMaps.isEmpty() && (int)seckillVoucherMaps.get("amount") > 0,
      "该券已经拥有了抢购活动");

  // 同步到 Redis
  seckillVouchers.setIsValid(1);
  seckillVouchers.setCreateDate(now);
  seckillVouchers.setUpdateDate(now);
  seckillVoucherMaps = BeanUtil.beanToMap(seckillVouchers);
  redisTemplate.opsForHash().putAll(redisKey, seckillVoucherMaps);
}

6.2 修改抢购业务

/**
* 抢购代金券
*
* @param voucherId  	代金券 ID
* @param accessToken    登录token
* @Para path 			访问路径
*/
@Transactional(rollbackFor = Exception.class)
public ResultInfo doSeckill(Integer voucherId, String accessToken, String path){
    
  // ----------采用 Redis 解问题----------
  String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + voucherId;
  Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);
  SeckillVouchers seckillVouchers = BeanUtil.mapToBean(seckillVoucherMaps,SeckillVouchers.class, true, null);
  
    
    // ----------采用 Redis 解问题----------
  // 扣库存
  long count = redisTemplate.opsForHash().increment(redisKey, "amount", -1);
  AssertUtil.isTrue(count < 0, "该券已经卖完了");
    return ResultInfoUtil.buildSuccess(path, "抢购成功");
}

6.3 测试

6.3.1 问题一 : 多扣库存问题

image.png
image.png

分析:因为 Redis 在扣除库存时抛出了 该券已经卖完了 的异常,导致后续代码不再执行所以订单符合逻辑。
解决:那可能大部分的人都会想到,直接把 Redis 扣库存的代码放在下单后执行不就可以了,我们来试一试。

6.3.2 问题二 : 超卖以及多扣库存问题

image.png
image.png

分析:虽然 Redis 在扣除库存时抛出了** 该券已经卖完了** 的异常,但是由于方法没有事务的异常回滚处理,订单也出现了超卖的问题。
解决:添加事务,我们再来试一试。

6.3.3 问题三 :多扣库存问题

image.png
image.png

分析:我们发现,又回到问题一的样子了。此时是因为 Redis 在扣除库存时抛出了 该券已经卖完了 的异常,由于方法有事务的异常回滚处理,所以订单是符合逻辑的,但是 Redis 缺还在扣除库存。因为Redis 这里实际上是一个查询库存再扣除库存的操作,并发场景下任然会出现问题,我们只需保证两个操作在同一个线程中执行即可,也就是保证它的原子性。
解决:采用 Lua 脚本。

在减库存时,使用的lua脚本操作了Redis,因为减库存时,我们需要判断系统库存够不够,然后才能减掉,这里是两个操作,如果分开独立执行,那么有可能会出现错误(因为客户端是多线程),因此我们采用lua脚本将两步操作放到一起同时在Redis中执行(Redis是单线程操作,故不会出现安全问题)。

7. Redis 之 Lua 脚本

7.1 Lua脚本

Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。主要应用在游戏开发、独立应用脚本、Web 应用脚本、扩展和数据库插件如:MySQL Proxy 和 MySQL WorkBench、安全系统,如入侵检测系统。

7.2 Redis 中 Lua 基本用法

7.2.1 执行脚本 eval
# 客户端执行Lua脚本
EVAL script numkeys key [key ...] arg [arg ...]

numkeys 是key的个数,后边接着写key1 key2… val1 val2…,举例:

127.0.0.1:0>eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 val1
val2
1)  "key1"
2)  "key2"
3)  "val1"
4)  "val2"

7.2.2 加载脚本 load
SCRIPT LOAD script 脚本内容

把脚本加载到脚本缓存中,返回SHA1校验和。但不会立马执行,举例

EVALSHA sha1 numkeys key [key ...] arg [arg ...]

7.2.3 从缓存中执行脚本内容
EVALSHA sha1 numkeys key [key ...] arg [arg ...]

举例 :

127.0.0.1:0>SCRIPT LOAD "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"
"a42059b356c875f0717db19a51f6aaca9ae659ea"
127.0.0.1:0> EVALSHA "a42059b356c875f0717db19a51f6aaca9ae659ea" 2 key1 key2 val1
val2
1)  "key1"
2)  "key2"
3)  "val1"
4)  "val2"

7.3 主要优势

  1. 减少网络开销
    2. 原子操作
    3. 复用
    4. 可嵌入性

7.4 执行脚本

1、编写减扣库存的脚本 /user/local/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("decr", KEYS[1]);
      return stock;
  end;
else
	return 0;
end;

2、用redis-cli执行脚本

redis-cli --eval stock.lua stock:001 -a 123456

8. Redis 限制一人一单

采用Redis分布式锁限制食客

8.1 锁的产生

锁是一种保护机制,在多线程的情况下,保证数据操作的一致性。

8.2 分布式锁实现

方式有三种:基于数据库;基于Zookeeper调度中心;基于Redis

8.3 分布式锁条件

实现分布式锁要满足3点:多进程可见,互斥,可重入。

8.3.1 多进程可见

Redis本身就是基于JVM之外的,因此满足多进程可见的要求。

8.3.2 互斥

同一时间只能有一个进程获取锁标记,我们可以通过redis的setnx实现,只有第一次执行的才会成功并
返回1,其它情况返回0。 setnx key value 将key设置值为value,如果key不存在,这种情况下等同
SET命令。 当key存在时,什么也不做。**SETNX是”SET if Not eXists”**的简写。

8.3.3 解决死锁

但是使用setnx命令设置锁会出现死锁情况,比如当我get lock以后出现了异常以后并没有将锁删除,而
且这把锁也没有过期时间,因此其他请求就再也获取不到这把锁了,这就是死锁。于是,后来Redis对
set 指令进行了改进,可以添加过期时间。当然有人会使用 expire 指令将key进行过期,但这样就不能
保证 setnx 和 expire 的原子操作了

SET KEY VALUE EX [seconds] PX [milliseconds] NX XX
# EX seconds – 设置键key的过期时间,单位时秒
# PX milliseconds – 设置键key的过期时间,单位时毫秒
# NX – 只有键key不存在的时候才会设置key的值
# XX – 只有键key存在的时候才会设置key的值

因此 set lock 123 EX 60 NX == setnx lock 123 + expire lock 60,而且set是原子操作,因此如果使
用最简单的Redis分布式锁的话就可以使用set指令

8.3.4 释放锁时BUG

① 3个进程:A和B和C,在执行任务,并争抢锁,此时A获取了锁,并设置自动过期时间为60s
②. A开始执行业务,因为某种原因,业务阻塞,耗时超过了60秒,此时锁自动释放了
③ B恰好此时开始尝试获取锁,因为锁已经自动释放,成功获取锁
④ A此时业务执行完毕,执行释放锁逻辑(删除key),于是B的锁被释放了,而B其实还在执行业务
⑥ 此时进程C尝试获取锁,也成功了,因为A把B的锁删除了。
 
问题出现了:B和C同时获取了锁,违反了互斥性!如何解决这个问题呢?我们应该在删除锁之前,判断这个锁是否是自己设置的锁,如果不是(例如自己 的锁已经超时释放),那么就不要删除了。所以我们可以在set 锁时,存入当前线程的唯一标识!删除锁前,判断下里面的值是不是与自己标识释放一 致,如果不一致,说明不是自己的锁,就不要删除了。
解决方法:解锁的时候必须是自己的锁才能解除,否则不能解除

8.4 可重入锁

重入锁,指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。可重入锁的意义在于防止死锁。
实现原理是通过为每个锁关联一个请求计数器和一个占有它的线程。当计数为0时,认为锁是未被占有的;线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数器置为1 。
如果同一个线程再次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减。直到计数器为0,锁被释放。
关于父类和子类的锁的重入:子类覆写了父类的synchonized方法,然后调用父类中的方法,此时如果没有重入的锁,那么这段代码将产生死锁

8.5 Redis可重入锁

设计思路
假设锁的key为“ lock ”,hashKey是当前线程的id:“ threadId ”,锁自动释放时间假设为20
获取锁的步骤:
  	1、判断lock是否存在 EXISTS lock
    2、不存在,则自己获取锁,记录重入层数为1.
    2、存在,说明有人获取锁了,下面判断是不是自己的锁,即判断当前线程id作为hashKey是否存在:HEXISTS lock threadId
    3、不存在,说明锁已经有了,且不是自己获取的,锁获取失败.
    3、存在,说明是自己获取的锁,重入次数+1: HINCRBY lock threadId 1 ,最后更新锁自动释放时间, EXPIRE lock 20
   
释放锁的步骤:
  	1、判断当前线程id作为hashKey是否存在: HEXISTS lock threadId
    2、不存在,说明锁已经失效,不用管了
    2、存在,说明锁还在,重入次数减1: HINCRBY lock threadId -1 ,
    3、获取新的重入次数,判断重入次数是否为0,为0说明锁全部释放,删除key: DEL lock

因此,存储在锁中的信息就必须包含:key、线程标识、重入次数。不能再使用简单的key-value结构,这里推荐使用hash结构。而且要让所有指令都在同一个线程中操作,那么使用lua脚本

8.5.1 在项目中集成

8.5.2 编写RedisLock类加载脚本

8.5.3 初始化Bean
@Configuration
public class RedisLockConfiguration {
  @Resource
  private RedisTemplate redisTemplate;
  @Bean
  public RedisLock redisLock() {
    RedisLock redisLock = new RedisLock(redisTemplate);
    return redisLock;
 }
}

8.5.4 分析业务修改Mapper和Service
// 根据食客 ID 和代金券 ID 及订单状态查询代金券订单
@Select("select id, order_no, fk_voucher_id, fk_diner_id, qrcode, payment," +
    " status, fk_seckill_id, order_type, create_date, update_date, " +
    " is_valid from t_voucher_orders where fk_diner_id = #{dinerId} " +
    " and fk_voucher_id = #{voucherId} and is_valid = 1 and status between 0
and 1 ")
VoucherOrders findDinerOrder(@Param("dinerId") Integer dinerId,
              @Param("voucherId") Integer voucherId);
package com.itkaka.seckill.mapper;

import com.itkaka.seckill.model.pojo.VoucherOrders;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

/**
 * 代金券订单 Mapper
 */
public interface VoucherOrdersMapper {

    //新增代金券订单
    @Insert("insert into t_voucher_orders (order_no, fk_voucher_id, fk_diner_id, `status`, fk_seckill_id, order_type, create_date, update_date, is_valid)"+
    " values (#{orderNo}, #{fkVoucherId}, #{fkDinerId}, #{status}, #{fkSeckillId}, #{orderType}, now(), now(), 1)")
    int save(VoucherOrders voucherOrders);

    //根据食客 ID 和代金券 ID 查询代金券订单
    @Select("select id ,order_no,fk_voucher_id, fk_diner_id, qrcode, payment, `status`, fk_seckill_id, order_type, create_date, update_date, is_valid"+
    " FROM t_voucher_orders WHERE fk_diner_id = #{dinerId} AND fk_voucher_id = #{voucherId} AND is_valid = 1 AND `status` between 0 and 1")
    VoucherOrders findDinerOrder(@Param("dinerId") Integer dinerId,
                                 @Param("voucherId") Integer voucherId);

}

8.5.5 业务层,修改SeckillService中的秒杀业务
// 判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(),
                           
seckillVouchers.getFkVoucherId());
AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢");
package com.itkaka.seckill.service;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.IdUtil;
import com.itkaka.commons.constant.ApiConstant;
import com.itkaka.commons.constant.RedisKeyConstant;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.seckill.mapper.SeckillVoucherMapper;
import com.itkaka.seckill.mapper.VoucherOrdersMapper;
import com.itkaka.seckill.model.pojo.RedisLock;
import com.itkaka.seckill.model.pojo.SeckillVouchers;
import com.itkaka.seckill.model.pojo.VoucherOrders;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * 秒杀业务逻辑层
 */
@Service
public class SeckillService {
    @Resource
    private SeckillVoucherMapper seckillVoucherMapper;
    @Resource
    private VoucherOrdersMapper voucherOrdersMapper;

    @Value("${service.name.fs-oauth-server}")
    private String oauthServerName;

    @Resource
    private RestTemplate restTemplate;
    @Resource
    private RedisTemplate redisTemplate;

    @Resource
    private DefaultRedisScript stockScript;

    @Resource
    private RedisLock redisLock;

    @Resource
    private RedissonClient redissonClient;


    // 添加需要抢购的代金券
    @Transactional(rollbackFor = Exception.class)
    public void addSeckillVouchers(SeckillVouchers seckillVouchers){
        //非空检验
        AssertUtil.isTrue(seckillVouchers.getFkVoucherId() == null,
                "请选择需要购买的代金券");
        AssertUtil.isTrue(seckillVouchers.getAmount() == 0,
                "请输入需要抢购的总数量");
        Date now = new Date();
        AssertUtil.isNotNull(seckillVouchers.getStartTime(),
                "请输入开始时间");
        AssertUtil.isTrue(now.after(seckillVouchers.getStartTime()),
                "开始时间不得早于当前时间");
        AssertUtil.isNotNull(seckillVouchers.getEndTime(),"请输入结束时间");
        AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()),
                "结束时间不得早于当前时间");
        AssertUtil.isTrue(seckillVouchers.getStartTime().after(seckillVouchers.getEndTime()),
                "结束时间不得早于开始时间");

        // ----------注释原始的 关系型数据库 的流程----------
        // 验证数据库中是否已经存在该券的秒杀活动
        // SeckillVouchers selectVoucher = seckillVouchersMapper.selectVoucher(seckillVouchers.getFkVoucherId());
        // AssertUtil.isTrue(selectVoucher != null, "该券已经拥有了抢购活动");
        // 插入数据库
        // seckillVouchersMapper.save(seckillVouchers);
        // ----------采用 Redis----------
        String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + seckillVouchers.getFkVoucherId();
        Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);
        // 验证 Redis 中是否已经存在该券的秒杀活动
        AssertUtil.isTrue(!seckillVoucherMaps.isEmpty() && (int) seckillVoucherMaps.get("amount") > 0,
                "该券已经拥有了抢购活动");
        // 插入 Redis
        seckillVouchers.setIsValid(1);
        seckillVouchers.setCreateDate(now);
        seckillVouchers.setUpdateDate(now);
        seckillVoucherMaps = BeanUtil.beanToMap(seckillVouchers);
        redisTemplate.opsForHash().putAll(redisKey, seckillVoucherMaps);
    }

    //抢购代金券
    @Transactional(rollbackFor = Exception.class)
    public ResultInfo doSeckill(Integer voucherId,String accessToken, String path){
        // 基本参数校验
        AssertUtil.isTrue(voucherId == null || voucherId <0,
                "请选择需要抢购的代金券");
        AssertUtil.isNotNull(accessToken,"请登录");

        // ----------注释原始的 关系型数据库 的流程----------
        // 判断此代金券是否加入抢购活动
        // SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId);
        // AssertUtil.isTrue(seckillVouchers == null, "该代金券并未有抢购活动");

        // ----------采用 Redis----------
        String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + voucherId;
        Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);
        AssertUtil.isTrue(seckillVoucherMaps.isEmpty() || seckillVoucherMaps.size() < 1,
                "该代金券并未有抢购活动");
        SeckillVouchers seckillVouchers = BeanUtil.mapToBean(seckillVoucherMaps, SeckillVouchers.class, true, null);

        // 判断是否有效
        AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束");
        // 判断是否开始、结束
        Date now = new Date();
        AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");
        AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");

        // 判断是否卖完通过 Lua 脚本扣库存时判断
        // AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完");

        // 获取用户信息
        String url = oauthServerName + "user/me?access_token={accessToken}";
        ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
            resultInfo.setPath(path);
            return resultInfo;
        }
        SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
                new SignInDinerInfo(), false);
        // 判断登录用户是否已抢到(一人一单)
        VoucherOrders voucherOrders = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(), voucherId);
        AssertUtil.isTrue(voucherOrders != null, "该用户已抢到该代金券,无需再抢");


        // ----------注释原始的 关系型数据库 的流程----------
        // 扣库存
        // int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId());
        // AssertUtil.isTrue(count == 0, "该券已经卖完了");

        // ----------采用 Redis----------
        // 扣库存
        // long count = redisTemplate.opsForHash().increment(redisKey, "amount", -1);
        // AssertUtil.isTrue(count < 1, "该券已经卖完了");

        // 使用 Redis 锁实现一个账号购买一次
        String lockName = RedisKeyConstant.lock_key.getKey() + dinerInfo.getId() + ":" + voucherId;
        // 获取锁
        long expireTime = seckillVouchers.getEndTime().getTime() - now.getTime();
        // 自定义 Redis 分布式锁
        // String lockKey = redisLock.tryLock(lockName, expireTime);

        // Redission 分布式锁
        RLock lock = redissonClient.getLock(lockName);

        try {
            // 锁不是 null,则下单
            // if (StrUtil.isNotBlank(lockKey)) {

            // Redission 分布式锁处理
            if (lock.tryLock(expireTime, TimeUnit.MILLISECONDS)) {
                // 下单
                VoucherOrders vo = new VoucherOrders();
                vo.setFkDinerId(dinerInfo.getId());
                // Redis 不需要维护秒杀活动的外键
                // vo.setFkSeckillId(seckillVouchers.getId());
                vo.setFkVoucherId(seckillVouchers.getFkVoucherId());
                String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();
                vo.setOrderNo(orderNo);
                vo.setOrderType(1);
                vo.setStatus(0);
                long count = voucherOrdersMapper.save(vo);
                AssertUtil.isTrue(count == 0, "用户抢购失败");

                // Lua 脚本扣库存
                List<String> keys = new ArrayList<>();
                keys.add(redisKey);
                keys.add("amount");
                Long amount = (Long) redisTemplate.execute(stockScript, keys);
                AssertUtil.isTrue(amount == null || amount < 1, "该券已经卖完了");
            }
        } catch (Exception e) {
            // 手动回滚事务
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
            // 自定义 Redis 分布式锁解锁
            // redisLock.unlock(lockName, lockKey);

            // Redission 解锁
            lock.unlock();
            if (e instanceof ParameterException) {
                return ResultInfoUtil.buildError(0, "该券已经卖完了", path);
            }
        }

        return ResultInfoUtil.buildSuccess(path,"抢购成功!");
    }

}

8.5.6 压力测试

8.6 引入Redisson分布式锁

Redisson 是一个高级的分布式协调 Redis 客服端。地址:https://github.com/redisson/redisson
它适应于多种场景:分布式应用,分布式缓存,分布式回话管理,分布式服务(任务,延迟任务,执行器),分布式redis客户端。
image.png
利用分布式锁功能

8.6.1 加依赖
<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.13.6</version>
</dependency>

8.6.2 对象注入
@Resource
private RedissonClient redissonClient;

8.6.3 代码修改完善
package com.itkaka.seckill.service;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.IdUtil;
import com.itkaka.commons.constant.ApiConstant;
import com.itkaka.commons.constant.RedisKeyConstant;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.seckill.mapper.SeckillVoucherMapper;
import com.itkaka.seckill.mapper.VoucherOrdersMapper;
import com.itkaka.seckill.model.pojo.RedisLock;
import com.itkaka.seckill.model.pojo.SeckillVouchers;
import com.itkaka.seckill.model.pojo.VoucherOrders;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * 秒杀业务逻辑层
 */
@Service
public class SeckillService {
    @Resource
    private SeckillVoucherMapper seckillVoucherMapper;
    @Resource
    private VoucherOrdersMapper voucherOrdersMapper;

    @Value("${service.name.fs-oauth-server}")
    private String oauthServerName;

    @Resource
    private RestTemplate restTemplate;
    @Resource
    private RedisTemplate redisTemplate;

    @Resource
    private DefaultRedisScript stockScript;

    @Resource
    private RedisLock redisLock;

    @Resource
    private RedissonClient redissonClient;

    //抢购代金券
    @Transactional(rollbackFor = Exception.class)
    public ResultInfo doSeckill(Integer voucherId,String accessToken, String path){
        // 基本参数校验
        AssertUtil.isTrue(voucherId == null || voucherId <0, "请选择需要抢购的代金券");
        AssertUtil.isNotNull(accessToken,"请登录");


        // ----------采用 Redis----------
        String redisKey = RedisKeyConstant.seckill_vouchers.getKey() + voucherId;
        Map<String, Object> seckillVoucherMaps = redisTemplate.opsForHash().entries(redisKey);
        AssertUtil.isTrue(seckillVoucherMaps.isEmpty() || seckillVoucherMaps.size() < 1,
                "该代金券并未有抢购活动");
        SeckillVouchers seckillVouchers = BeanUtil.mapToBean(seckillVoucherMaps, SeckillVouchers.class, true, null);

        // 判断是否有效
        AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束");
        // 判断是否开始、结束
        Date now = new Date();
        AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始");
        AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束");

        // 判断是否卖完通过 Lua 脚本扣库存时判断
        // AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完");

        // 获取用户信息
        String url = oauthServerName + "user/me?access_token={accessToken}";
        ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken);
        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
            resultInfo.setPath(path);
            return resultInfo;
        }
        SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),
                new SignInDinerInfo(), false);
        // 判断登录用户是否已抢到(一人一单)
        VoucherOrders voucherOrders = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(), voucherId);
        AssertUtil.isTrue(voucherOrders != null, "该用户已抢到该代金券,无需再抢");

        // 使用 Redis 锁实现一个账号购买一次
        String lockName = RedisKeyConstant.lock_key.getKey() + dinerInfo.getId() + ":" + voucherId;
        // 获取锁
        long expireTime = seckillVouchers.getEndTime().getTime() - now.getTime();
        // 自定义 Redis 分布式锁
        // String lockKey = redisLock.tryLock(lockName, expireTime);

        // Redission 分布式锁
        RLock lock = redissonClient.getLock(lockName);

        try {
            // 锁不是 null,则下单
            // if (StrUtil.isNotBlank(lockKey)) {

            // Redission 分布式锁处理
            if (lock.tryLock(expireTime, TimeUnit.MILLISECONDS)) {
                // 下单
                VoucherOrders vo = new VoucherOrders();
                vo.setFkDinerId(dinerInfo.getId());
                // Redis 不需要维护秒杀活动的外键
                // vo.setFkSeckillId(seckillVouchers.getId());
                vo.setFkVoucherId(seckillVouchers.getFkVoucherId());
                String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr();
                vo.setOrderNo(orderNo);
                vo.setOrderType(1);
                vo.setStatus(0);
                long count = voucherOrdersMapper.save(vo);
                AssertUtil.isTrue(count == 0, "用户抢购失败");

                // Lua 脚本扣库存
                List<String> keys = new ArrayList<>();
                keys.add(redisKey);
                keys.add("amount");
                Long amount = (Long) redisTemplate.execute(stockScript, keys);
                AssertUtil.isTrue(amount == null || amount < 1, "该券已经卖完了");
            }
        } catch (Exception e) {
            // 手动回滚事务
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
            // 自定义 Redis 分布式锁解锁
            // redisLock.unlock(lockName, lockKey);

            // Redission 解锁
            lock.unlock();
            if (e instanceof ParameterException) {
                return ResultInfoUtil.buildError(0, "该券已经卖完了", path);
            }
        }

        return ResultInfoUtil.buildSuccess(path,"抢购成功!");
    }

}

8.6.4 压力测试

写在最后

抢购优惠券
这个功能中我们实现了抢购秒杀完整的一套业务,解决了超卖、限制一人一单的问题。
这个功能中 Redis 主要用于实现分布式锁、Lua脚本,使用了 Hash 数据类型,分别使用了 原生方式 和 Redisson 的方式,

👉 💕美好的一天,从现在开始,大家一起努力!后续持续更新,码字不易,麻烦大家小手一点 , 点赞或关注 , 感谢大家的支持!! 🌙

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值