1. 需求说明
1.1 说明:
- 设置抢购活动,比如活动对应代金券,开始时间,结束时间,秒杀券数量等
- 定时开始抢购活动,禁止超卖
- 用户抢购限制, 一个用户只能购买一单
1.2 表结构设计
代金券表
抢购活动表
订单表
2. 解决方案
秒杀场景特点 : 大量用户同时抢购 ; 请求数量远大于商品库存量, 只有少数客户可以抢购成功; 业务流程不复杂,核心功能是下订单。
秒杀场景从以下几方面进行应对 :
- 限流
- 缓存
- 异步
- 分流
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 添加秒杀活动
- 非空校验
- 活动时间校验
- 验证数据库是否已经存在该券的秒杀活动
- 插入数据库在 ms-gateway 网关中放行,此接口为平台后台调用,不需要食客登录
4.4.1 PostMan 测试
4.5 客户端秒杀
- 基本参数校验
- 判断此代金券是否加入抢购
- 是否有效
- 是否开始 、 结束
- 是否卖完
- 登录用户信息
- 判断登录用户是都已抢到 (一个用户一次活动只能买一次)
- 扣库存
- 下单
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,说明卖多了
2)模拟某个用户多次抢购:模拟10000个并发,1个账号进行抢购
结果后台会报错,同时订单表会出现针对一个voucher一个用户多个订单
先 TRUNCATE t_voucher_orders 清空数据,修改 t_seckill_vouchers amount 为 100。
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 问题一 : 多扣库存问题
分析:因为 Redis 在扣除库存时抛出了 该券已经卖完了 的异常,导致后续代码不再执行所以订单符合逻辑。
解决:那可能大部分的人都会想到,直接把 Redis 扣库存的代码放在下单后执行不就可以了,我们来试一试。
6.3.2 问题二 : 超卖以及多扣库存问题
分析:虽然 Redis 在扣除库存时抛出了** 该券已经卖完了** 的异常,但是由于方法没有事务的异常回滚处理,订单也出现了超卖的问题。
解决:添加事务,我们再来试一试。
6.3.3 问题三 :多扣库存问题
分析:我们发现,又回到问题一的样子了。此时是因为 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 主要优势
- 减少网络开销
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客户端。
利用分布式锁功能
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 的方式,
👉 💕美好的一天,从现在开始,大家一起努力!后续持续更新,码字不易,麻烦大家小手一点 , 点赞或关注 , 感谢大家的支持!! 🌙