秒杀项目练习笔记
这篇笔记整理秒杀项目的练习过程,重点包括登录校验、Session 共享、基础秒杀流程、压测、页面优化、防重复购买、防超卖、Redis + RabbitMQ 异步削峰、安全防刷和分布式锁。
| 模块 | 重点内容 |
|---|---|
| 基础能力 | 用户登录、参数校验、全局异常、Session 与 Cookie |
| 秒杀流程 | 商品列表、秒杀订单、库存扣减、重复购买与超卖处理 |
| 性能优化 | JMeter 压测、页面缓存、Redis 预减库存、内存标记、RabbitMQ 异步处理 |
| 安全防护 | 秒杀地址隐藏、验证码、防刷限流 |
| 并发控制 | Redis 分布式锁、原子扣减和库存一致性 |
秒杀方案
- 秒杀/高并发主要解决两个问题, 并发读, 并发写
- 并发读核心优化理念就是尽量减少读 DB, 或者读更少的数据, 并发写也是一样的
- 针对秒杀系统需要做一些防护, 针对意料做设计兜底, 防止意外发生
- 系统架构要满足高可用: 流量复合预期, 要稳定, 保证秒杀活动顺利完成, 秒杀商品顺利的卖出去, 这个是前提
- 系统保证数据的一致性, 就是秒杀10个商品, 那就只能成交10个商品, 多一个少一个都不行, 一旦库存不对, 就要承担损失.
- 系统要满足高性能, 也就是系统的性能要足够高, 需要支撑大流量, 不光是服务端要做到极致的性能优化, 而且在整个请求链路上都要做协同优化
- 秒杀涉及大量的并发读和并发写, 因此支持高并发访问非常关键, 对应方案: 页面缓存(静态化), Redis 预减库存/内存标记与隔离, 请求削峰(RabbitMQ 异步请求), 分布式 Session 共享
设计
编码
数据库
create database seckill charset utf8mb4 collate utf8mb4_general_ci;
1. 用户登录、参数校验与全局异常处理
用户表
drop table if exists `seckill_user`;
create table seckill_user
(
id bigint not null comment '用户ID, 主键, 手机号',
nickname varchar(255) not null default '',
`password` varchar(32) not null default '' comment 'md5(md5(pass明文+固定salt)+salt)',
`salt` varchar(10) not null default '',
`head` varchar(128) not null default '' comment '头像',
`register_date` datetime default null comment '注册时间',
`last_login_date` datetime default null comment '最后一次登录时间',
`login_count` int(11) default '0' comment '登录次数',
primary key (id)
) engine = InnoDB charset = utf8mb4;
-- 测试数据
-- 13312345678/13312345678 salt=12345678
insert into seckill_user(id, nickname, password, salt, head, register_date, last_login_date, login_count)
VALUES ('13312345678', 'zangxin', '77ac31522b6361cb71c69f9ad0c45f76', '12345678', '', current_timestamp(), current_timestamp(), 1);
密码加密问题:
方式1 前端明文–>后端 MD5(明文)–>数据库
方式二 前端 MD5(明文)–>后端(MD5(MD5(明文))—>DB
方式三 前端 MD5(明文+salt1)–>后端(MD5(MD5(明文+salt1)+salt2)
每个用户的salt都不一样
需求: 用户登录
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public ResponseBean login(LoginVo loginVo) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
// 判空
if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) {
return ResponseBean.error(ResponseBeanEnum.LOGIN_ERROR);
}
// 手机号码格式
if (!ValidatorUtil.isMobile(mobile)) {
return ResponseBean.error(ResponseBeanEnum.MOBILE_ERROR);
}
// 查询数据库
User user = userMapper.selectById(mobile);
if (null == user) {// user not exist
return ResponseBean.error(ResponseBeanEnum.MOBILE_NOT_EXIST);
}
// 校验密码, LoginVo这里拿到的密码是mid密码, 客户端已经第一次加盐了
String salt2 = user.getSalt();
String dbPass = MD5Util.midPassToDBPass(password, salt2);
if (!dbPass.equals(user.getPassword())) {
return ResponseBean.error(ResponseBeanEnum.LOGIN_ERROR);
}
return ResponseBean.success();
}
1
2
3
4
5
@Data
public class LoginVo {
private String mobile;
private String password;
}
自定义注解来校验参数(手机号/密码)
1
2
3
4
5
6
7
8
9
10
11
12
13
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class})
public @interface IsMobile {
String message() default "手机号码格式错误";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
boolean required() default true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {
private boolean required = false;
@Override
public void initialize(IsMobile anno) {
required = anno.required();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
// 必填手机号
if (required) {
return ValidatorUtil.isMobile(value);
} else {
if (!StringUtils.hasText(value)) { // 为空时不校验
return true;
} else {
return ValidatorUtil.isMobile(value);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
// 使用注解
@Data
public class LoginVo {
@IsMobile(message = "你很伟大~")
@NotNull
private String mobile;
@NotNull
@Length(min = 32)
private String password;
}
1
2
3
4
5
6
7
@RequestMapping("/doLogin")
@ResponseBody
// 必须加@Validated
public ResponseBean doLogin(@Validated LoginVo loginVo) {
log.info("登录入参: loginVo={}", loginVo);
return userService.login(loginVo);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 改进后的service
@Override
public ResponseBean login(LoginVo loginVo) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
// 查询数据库
User user = userMapper.selectById(mobile);
if (null == user) {// user not exist
throw new GlobalException(ResponseBeanEnum.MOBILE_NOT_EXIST);
}
// 校验密码, LoginVo这里拿到的密码是mid密码, 客户端已经第一次加盐了
String salt2 = user.getSalt();
String dbPass = MD5Util.midPassToDBPass(password, salt2);
if (!dbPass.equals(user.getPassword())) {
throw new GlobalException(ResponseBeanEnum.LOGIN_ERROR);
}
return ResponseBean.success();
}
全局异常处理
1
2
3
4
5
6
7
// 全局异常类
@Data
@AllArgsConstructor
public class GlobalException extends RuntimeException{
private ResponseBeanEnum responseBeanEnum;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 全局异常处理类
@RestControllerAdvice
public class GlobalExceptionHandler {
// 处理所有的异常
@ExceptionHandler(Exception.class)
public ResponseBean ExceptionHandler(Exception e) {
// 如果是全局异常,正常处理
if (e instanceof GlobalException) {
GlobalException ex = (GlobalException) e;
return ResponseBean.error(ex.getResponseBeanEnum());
} else if (e instanceof BindException) {
// 如果是绑定异常 :由于我们自定义的注解只会在控制台打印错误信息,想让该信息传给前端。
// 需要获取改异常 BindException中注解定义消息,返回给前端
BindException ex = (BindException) e;
ResponseBean respBean = ResponseBean.error(ResponseBeanEnum.BING_ERROR);
// respBean.setMessage("参数校验异异常");
String defaultMessage = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
respBean.setMessage(defaultMessage);
return respBean;
}
// 兜底异常
return ResponseBean.error(ResponseBeanEnum.ERROR);
}
}
2. Session 共享
用户登录成功后需要保存 Session, 保持登录状态, 然后进入到商品页面
给登录成功的用设置一个 Cookie: userTicket
1
2
3
4
5
6
7
// 用户登录成功
// 给每一个用户生成一个ticket-唯一的标识一个用户
String ticket = UUIDUtil.uuid();
// 将用户登录信息保存到session中
request.getSession().setAttribute(ticket, user);
// 设置cookie
CookieUtil.setCookie(request, response, "userTicket", ticket);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 商品页面会对登录进行校验
@RequestMapping("/toList")
public String toList(HttpSession session, Model model,
@CookieValue(value = "userTicket", required = false) String userTicket) {
User user = (org.xxx.seckill.pojo.User) session.getAttribute(userTicket);
if (!StringUtils.hasText(userTicket)) {
return "login";
}
if (null == user) { // 用户没有成功登录,或者userTicket不正确
return "login";
}
// 将用户信息放入到model中, 给模板使用
model.addAttribute("user", user);
return "goodsList";
}
分布式 Session 问题
1、当 Nginx 对请求进行负载均衡后,可能对应到不同的 Tomcat
2、比如第1 次请求,均衡到 Tomcat A, 这时 Session 就记录在 Tomcat A, 第 2 次请求,均衡到 Tomcat B, 这时就出现问题了,因为 Tomcat B 会认为该用户是第 1 次来,就会 允许购买请求
3、这样就会造成重复购买
解决方案
Session 绑定 / 粘滞

概述: 服务器会把某个用户的请求交给 Tomcat 集群中的一个节点,以后此节点就负责该保存该用户的 Session
1) Session 绑定可以利用负载均衡的源地址 Hash(ip_hash)算法实现
2) 负载均衡服务器总是将来源手同一个 IP 的请求分发到同一台服务器上,也可以根
据 Cookie 信息将同一个用户的请求总是分发到同一台服务器上
3) 这样整个会话期间,该用户所有的请求都在同一台服务器上处理,即 Session 绑定 在某台特定服务器上,保证 Session 总能在这台服务器上获取。这种方法又被称为 Session 黏滞/粘滞
优点: 不占用服务端内存
缺点: 1) 增加新机器,会重新 Hash,导致重新登录 2) 应用重启, 需要重新登录 3) 某台服务器宕机,该机器上 的 Session 也就不存在了,用户请求切换到其他机器后因为没有 Session 而无法完成业务处理,这种方案不符合 系统高可用需求, 使用较少
Session 复制

Session 复制是小型架构使用较多的一种服务器集群 Session 管理机制
-
应用服务器开启 Web 容器的 Session 复制功能,在集群中的几台服务器之间同步 Session 对象,使每台服务器上都保存了所有用户的 Session 信息
-
这样任何一台机器宕机都不会导致 Session 数据的丢失,而服务器使用 Session 时,也只需要在本机获取即可
优点: 无需修改代码,修改 Tomcat 配置即可
缺点: Session 同步战术内网带宽, 多台 Tomcat 同步性能下降, Session 占用内存
前端存储
优点: 不占用服务端内存
缺点: 1) 存在安全风险 2) 数据大小受 Cookie 限制 3) 占用外网带宽
后端集中存储
优点: 安全,容易水平扩展(Session 放 Redis, 读 Session 从 Redis 中读取)
缺点: 增加复杂度,需要修改代码
分布式 Session 解决方案 1:Spring Session 实现分布式 Session(后端集中存储)
一句话:将用户 Session 不再存放到各自登录的 Tomcat 服务器,而是统一存放到 Redis,从而解决 Session 分布式问题
需求: 把用户登录成功的 Session 信息保存在指定的 Redis 中
配置 Redis: 192.168.2.85:6379, 可视化工具: another Redis desktop manager
引入 spring-session 依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--redis依赖-->
<!--spring data redis 依赖, 即 spring 整合 redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> <!--pool2 对象池依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--实现分布式 session, 即将 Session 保存到指定的 Redis-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
配置文件:只需要配置 Redis
1
2
3
4
5
6
7
8
9
10
11
12
13
# redis
spring.redis.host=192.168.2.85
spring.redis.port=6379
spring.redis.database=0
spring.redis.timeout=10000ms
#最大连接数:默认是 8
spring.redis.lettuce.pool.max-active=20
#最大连接阻塞等待时间,默认是-1
spring.redis.lettuce.pool.max-wait=10000ms
#最大空闲连接,默认是 8
spring.redis.lettuce.pool.max-idle=200
#最小空闲连接,默认是 0
spring.redis.lettuce.pool.min-idle=5
登录一下, Redis 中就有 Session 数据了结果

-
前面将 Session 统一存放指定 Redis, 是以原生的形式存放, 在操作时, 还需要反序列化,不方便
-
我们进行改进: 直接将登录用户信息统一存放到 Redis, 利于操作
具体步骤:
注释掉 spring-session-data-redis 依赖
自定义redisTemplate实现序列化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置相应 key 的序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());
// value 序列化
// redis 默认是 jdk 的序列化是二进制,这里使用的是通用的 json 数据,不用传具体的序列化的对象
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// 设置相应的 hash 序列化
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); // 注入连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}
修改登录成功的service代码-存放用户信息到 Redis 中
1
2
3
4
5
6
7
8
// 用户登录成功
// 给每一个用户生成一个ticket-唯一的标识一个用户
String ticket = UUIDUtil.uuid();
// 将用户登录信息保存到session中
// request.getSession().setAttribute(ticket, user);
redisTemplate.opsForValue().set("user:" + ticket, user);
CookieUtil.setCookie(request, response, "userTicket", ticket);
return ResponseBean.success();
service增加方法, 根据 Cookie 获取user
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 根据cookie从redis中获取用户
* @param userTicket 登录后给用户分配标志
* @return user
*/
@Override
public User getUserByCookie(String userTicket,HttpServletRequest request, HttpServletResponse response) {
if (!StringUtils.hasText(userTicket)) {
return null;
}
User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
if (user != null) {
// 如果user不为null, 刷新cookie的存活时间
CookieUtil.setCookie(request, response, "userTicket", userTicket);
}
return user;
}
controller代码修改-登录校验
1
User user = userService.getUserByCookie(userTicket, request, response);
再次登录(http://127.0.0.1/goods/toList)查看 Redis

实现 WebMvcConfigurer, 优化登录
1、获取浏览器传递的 Cookie 值,进行参数解析,直接转成 User 对象,继续传递.
节省controller的参数列表:
1
2
3
@RequestMapping("/toList")
public String toList(HttpSession session, Model model, HttpServletRequest request, HttpServletResponse response,
@CookieValue(value = "userTicket", required = false) String userTicket) {}
变成
1
2
@RequestMapping("/toList")
public String toList(Model model, User user)
实现步骤
定义参数解析器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
@Autowired
private UserService userService;
//如果这个方法返回 true 才会执行下面的 resolveArgument 方法
// 返回 false 不执行下面的方法
@Override
public boolean supportsParameter(MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();
return User.class == parameterType;
}
// * 这个方法,类似拦截器,将传入的参数,取出 cookie 值,然后获取对应的 User 对象
// * 并把这个 User 对象作为参数继续传递.
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
String userTicket = CookieUtil.getCookieValue(request, "userTicket");
if (!StringUtils.hasText(userTicket)) {
return null;
}
//根据 cookie-ticket 到 Redis 获取 User
User user = userService.getUserByCookie(userTicket, request, response);
return user;
}
}
注册解析器
1
2
3
4
5
6
7
8
9
10
11
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Autowired
private UserArgumentResolver userArgumentResolver;
// 自定义参数解析
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
修改controller 方法入参
1
2
3
4
5
6
7
8
9
@RequestMapping("/toList")
public String toList(Model model, User user) {
if (null == user) { // 用户没有成功登录,或者userTicket不正确
return "login";
}
// 将用户信息放入到model中, 给模板使用
model.addAttribute("user", user);
return "goodsList";
}
3. 基本秒杀功能
秒杀商品列表
登录成功后, 进入商品列表页面: http://127.0.0.1/goods/toList, goodsList.html
商品表
-- 商品表
DROP TABLE IF EXISTS `t_goods`;
CREATE TABLE `t_goods`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '商品 id',
`goods_name` VARCHAR(16) not null DEFAULT '',
`goods_title` VARCHAR(64) not null DEFAULT '' COMMENT '商品标题',
`goods_img` VARCHAR(64) not null DEFAULT '' COMMENT '商品图片',
`goods_detail` LONGTEXT not null COMMENT '商品详情',
`goods_price` DECIMAL(10, 2) DEFAULT '0.00' COMMENT '商品价格',
`goods_stock` INT(11) DEFAULT '0' COMMENT '商品库存',
PRIMARY KEY (`id`)
) ENGINE = INNODB
AUTO_INCREMENT = 3
DEFAULT CHARSET = utf8mb4;
-- 测试数据
INSERT INTO `t_goods` VALUES ('1', '整体厨房设计-套件', '整体厨房设计-套件', '/imgs/kitchen.jpg', '整体厨房设计-套件', '15266.00', '100');
INSERT INTO `t_goods` VALUES ('2', '学习书桌-套件', '学习书桌-套件', '/imgs/desk.jpg', '学习书桌-套件', '5690.00', '100');
秒杀商品表
-- 秒杀商品表
DROP TABLE IF EXISTS `t_seckill_goods`;
CREATE TABLE `t_seckill_goods`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`goods_id` BIGINT(20) DEFAULT 0,
`seckill_price` DECIMAL(10, 2) DEFAULT '0.00',
`stock_count` INT(10) DEFAULT 0,
`start_date` DATETIME DEFAULT NULL,
`end_date` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE = INNODB
AUTO_INCREMENT = 3
DEFAULT CHARSET = utf8mb4;
INSERT INTO `t_seckill_goods` VALUES ('1', '1', '5266.00', '0', '2022-11-18 19:36:00', '2022-11-19 09:00:00');
INSERT INTO `t_seckill_goods` VALUES ('2', '2', '690.00', '10', '2022-11-18 08:00:00', '2022-11-19 09:00:00');
生成对应的mapper, pojo, mapper.xml文件
显示商品列表的实体类GoodsVo
1
2
3
4
5
6
7
8
9
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoodsVo extends Goods {
private BigDecimal seckillPrice;
private Integer stockCount;
private Date startDate;
private Date endDate;
}
商品详情页面: 根据goods查询商品详情
1
2
3
4
5
6
7
8
9
10
11
12
// 商品详情页
@RequestMapping("/toDetail/{goodsId}")
public String toGoodsDetail(@PathVariable("goodsId") Long goodsId,
Model model, User user) {
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goodsVo);
model.addAttribute("user", user);
return "goodsDetail";
}
秒杀状态: 秒杀倒计时, 秒杀进行中, 秒杀结束
秒杀剩余时间
实现: 根据秒杀开始时间-秒杀结束时间的区间来判断状态和剩余时间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 商品详情页
@RequestMapping("/toDetail/{goodsId}")
public String toGoodsDetail(@PathVariable("goodsId") Long goodsId,
Model model, User user) {
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
Date startTime = goodsVo.getStartDate();
Date endTime = goodsVo.getEndDate();
Date now = new Date();
// 秒杀状态 0: 未开始, 1:进行中 2:结束
Integer status = 0;
// 还剩多久秒杀开始
Long remainSeconds = 0L;
if (now.before(startTime)) {
status = 0;
remainSeconds = (startTime.getTime() - now.getTime()) / 1000;
} else if (now.after(startTime) && now.before(endTime)) {
remainSeconds = 0L;
status = 1;
} else if (now.after(endTime)) {
status = 2;
remainSeconds = -1L;
}
model.addAttribute("secKillStatus", status);
model.addAttribute("remainSeconds", remainSeconds);
model.addAttribute("goods", goodsVo);
model.addAttribute("user", user);
return "goodsDetail";
}

秒杀按钮
-
秒杀按钮的状态会变化
-
如果秒杀没有开始-不可用状态
-
秒杀开始-可用
-
秒杀结束-不可用状态
buy按钮
1
<button class="btn btn-primary btn-block" type="submit" onclick="getSecKillPath()" id="buyButton">
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$(function () {
countDown(); //会执行countDown()
});
function countDown() {
//获取剩余时间
var remainSeconds = $("#remainSeconds").val();
var timeout; //定时器/计时器
//秒杀还未开始
if (remainSeconds > 0) {
$("#buyButton").attr("disabled", true);//抢购的按钮设置成不可用
timeout = setTimeout(function () {
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
}, 1000);
} else if (remainSeconds == 0) {//秒杀进行中
$("#buyButton").attr("disabled", false);
if (timeout) {//清空计时器
clearTimeout(timeout);
}
$("#seckillTip").html("秒杀进行中");
} else {
$("#buyButton").attr("disabled", true);//抢购的按钮设置成不可用
$("#seckillTip").html("秒杀已结束");
}
}
秒杀接口开发
秒杀成功–进入订单确认页
秒杀失败–秒杀失败页面(库存限购, 每人只能买一个商品)
订单表
-- 普通订单表,记录订单完整信息
DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) NOT NULL DEFAULT 0,
`goods_id` BIGINT(20) NOT NULL DEFAULT 0,
`delivery_addr_id` BIGINT(20) NOT NULL DEFAULT 0,
`goods_name` VARCHAR(16) NOT NULL DEFAULT '',
`goods_count` INT(11) NOT NULL DEFAULT '0',
`goods_price` DECIMAL(10, 2) NOT NULL DEFAULT '0.00',
`order_channel` TINYINT(4) NOT NULL DEFAULT '0' COMMENT '订单渠道 1pc,2Android, 3ios',
`status` TINYINT(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0 新建未支付 1 已支付 2已发货 3已收货 4已退款 5已完成',
`create_date` DATETIME DEFAULT NULL,
`pay_date` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE = INNODB
AUTO_INCREMENT = 600
DEFAULT CHARSET = utf8mb4;
秒杀订单表–记录用户id-商品id-订单id
-- 秒杀订单表,记录某用户 id,秒杀的商品 id,及其订单 id
DROP TABLE IF EXISTS `t_seckill_order`;
CREATE TABLE `t_seckill_order`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) NOT NULL DEFAULT 0,
`order_id` BIGINT(20) NOT NULL DEFAULT 0,
`goods_id` BIGINT(20) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `seckill_uid_gid` (`user_id`, `goods_id`) USING BTREE COMMENT ' 用户 id,商品 id 的唯一索引,解决同一个用户多次抢购'
) ENGINE = INNODB
AUTO_INCREMENT = 300
DEFAULT CHARSET = utf8mb4;
第一版秒杀代码
service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 需要考虑事务, 高并发
@Override
public Order seckill(User user, GoodsVo goodsVo) {
Assert.notNull(user, "用户没有登录");
Long userId = user.getId();
Assert.notNull(goodsVo, "秒杀商品goods不能为空");
// 查询秒杀商品的库存-扣减库存
SeckillGoods seckillGoods = new LambdaQueryChainWrapper<>(seckillGoodsMapper)
.eq(SeckillGoods::getGoodsId, goodsVo.getId()).one();
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
int update = seckillGoodsMapper.updateById(seckillGoods);
// 生成普通订单
Order order = new Order();
order.setUserId(userId);
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L); // 设置一个默认值
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
order.setOrderChannel((byte) 1);
order.setStatus((byte) 0);
order.setCreateDate(new Date());
this.save(order);
// 生成秒杀商品订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(userId);
seckillOrder.setGoodsId(goodsVo.getId());
seckillOrderMapper.insert(seckillOrder);
// 返回order用来回显订单信息
return order;
}
controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 处理用户抢购请求
@RequestMapping("/doSeckill")
public String doSeckill(User user, Model model, Long goodsId) {
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断是否有库存
if (goodsVo.getStockCount() < 1) {// 无库存
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 判断用户是否重复购买
SeckillOrder seckillOrder = seckillOrderService.getOne(
new LambdaQueryWrapper<SeckillOrder>()
.eq(SeckillOrder::getGoodsId, goodsId)
.eq(SeckillOrder::getUserId, user.getId())
);
if (null != seckillOrder) {
model.addAttribute("errmsg", ResponseBeanEnum.LIMIT_BUY_ONE);
return "secKillFail";
}
// 生成订单
Order order = orderService.seckill(user, goodsVo);
if (null == order) {
model.addAttribute("errmsg", "秒杀失败, 其他原因");
return "secKillFail";
}
// 秒杀成功进入订单页面
model.addAttribute("order", order);
return "orderDetail";
}
1
2
3
4
<form id="secKillForm" method="post" action="/seckill/doSeckill">
<input type="hidden" id="goodsId" name="goodsId" th:value="${goods.id}">
<button class="btn btn-primary btn-block" type="submit" id="buyButton">抢购</button>
</form>
测试



抢购成功

秒杀基本功能完成–下单-扣减库存–判断是否重复购买
高并发版本还需改进
4. 秒杀压测
压测工具jmeter的使用
压测秒杀接口/seckill/doSeckill
准备工作
- 批量生成用户的脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/**
* @author zangxin
* @version 1.0
* @date 2025/4/11
* 生成用户工具类
* 创建用户,并且去登录得到 userticket,
* 得到的 userTicket 写入到 config.txt 文件内
*/
public class UserUtil {
public static void create(int count) throws Exception {
List<User> users = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
// 用户数据表的 slat,由程序员设置
//?是用户原始密码,比如 12345 , hello 等
User user = new User();
user.setId(13300000100L + i);
user.setNickname("user" + i);
user.setSalt("P5XT7DiE");
user.setPassword(MD5Util.inputPassToDBPass("12345", user.getSalt()));
users.add(user);
}
System.out.println("create user");
// 插入数据库
Connection connection = getConn();
String sql = "insert into seckill_user(nickname,salt,password,id) values(?,?,?,?)";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
preparedStatement.setString(1, user.getNickname());
preparedStatement.setString(2, user.getSalt());
preparedStatement.setString(3, user.getPassword());
preparedStatement.setLong(4, user.getId());
preparedStatement.addBatch();
}
preparedStatement.executeBatch();
// 关闭 connection.close();
preparedStatement.clearParameters();
System.out.println("insert to do");
// 登录拿到 userTicket
String urlStr = "http://localhost:80/login/doLogin";
File file = new File("/Users/qingsongcai/devtools/apache-jmeter-5.6.3/jmeter_data_zangxin/config.txt");
if (file.exists()) {
file.delete();
}
RandomAccessFile raf = new RandomAccessFile(file, "rw");
raf.seek(0);
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
// 请求
URL url = new URL(urlStr);
HttpURLConnection co = (HttpURLConnection) url.openConnection();
co.setRequestMethod("POST");
// 设置输入网页密码(相当于输出到页面)
co.setDoOutput(true);
OutputStream outputStream = co.getOutputStream();
String params = "mobile=" + user.getId() + "&password=" +
MD5Util.inputPassToMidPass("12345");
outputStream.write(params.getBytes());
outputStream.flush();
// 获取网页输出 ,( 得 到 输 入 流 , 把 结 果 得 到 , 再 输 出 到 ByteArrayOutputStream 内)
InputStream inputStream = co.getInputStream();
ByteArrayOutputStream bout = new ByteArrayOutputStream();
byte[] bytes = new byte[1024];
int len = 0;
while ((len = inputStream.read(bytes)) >= 0) {
bout.write(bytes, 0, len);
}
inputStream.close();
bout.close();
// 把 ByteArrayOutputStream 内的东西转换为 respBean 对象
String response = new String(bout.toByteArray());
ObjectMapper mapper = new ObjectMapper();
ResponseBean respBean = mapper.readValue(response, ResponseBean.class);
// 得到 userTicket, 需要在/login/doLogin返回数据中设置userTicket
String userTicket = (String) respBean.getObj();
System.out.println("create userTicket" + userTicket);
String row = user.getId() + "," + userTicket;
// 写入指定文件
raf.seek(raf.length());
raf.write(row.getBytes());
raf.write("\r\n".getBytes());
System.out.println("write to file:" + user.getId());
}
raf.close();
System.out.println("over");
}
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 {
create(2000);
}
}
生成2000条用户数据

登录数据存储到 Redis 中(2000个key)

config.txt 2000条
1
2
3
4
5
6
7
8
13300000100,2067c2b47ce940a79c7d2b813d41d2e1
13300000101,b2c11ecb06ad4687adcde69eb7adc08e
13300000102,145b5a2c18fb4de3b0bd95c2de7ae08f
13300000103,1b1886f43c24436294b39f6c1befc59c
13300000104,71b31979ca084df2bf29de245d273461
13300000105,58ee08f2cccf43cb8965226cd8e358ce
13300000106,418b46741c7b47ab8b43545c342020c9
...
配置jmeter
秒杀接口: /seckill/doSeckill

csv数据

Cookie 数据

配置结果树和汇总报告等监听器
配置库存为10个, 商品id-goodsId=1

线程组: 线程数:1000, 循环次数10, ramp-up时间:0
吞吐量1464

存在超卖问题

而且订单生成也有问题
明明只有10个库存商品, 却生成了200个订单—在库存判断时, 没有考虑多超卖问题


压测数据库sql语句
-- 压测,清理数据,避免用户限购一次的问题
delete from t_order where 1=1;
delete from t_seckill_order where 1=1;
update t_seckill_goods set stock_count = 10;
-- 查看结果
select count(*) as '普通订单数量' from t_order;
select count(*) as '秒杀订单数量' from t_seckill_order;
select stock_count from t_seckill_goods where goods_id =1;
5. 页面优化
1) 多用户在查看商品列表和商品详情的时候每一个用户都需要到 DB 查询
2) 对 DB 查询的压力很大,比如 10000 人, 在 1 分钟都查看商品列表 就会有 10000 次对 DB 操作
3) 但是我们商品信息并不会频繁的变化, 所以你查询回来的结果都是一样的
4) 我们可以通过 Redis缓存页面来进行优化, 这样可以将1分钟内多次查询 DB, 优化成1次查询, 减少 DB 压力
分析:

第一次请求时, Redis 中没有, 去 DB 中获取, 返回给客户端, 顺带着把数据放到 Redis 中, 以后访问就从 Redis 中读取了
商品列表缓存(/goods/toList)
具体code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 手动渲染模板要使用
@Autowired
private ThymeleafViewResolver thymeleafViewResolver;
// 使用了 redis 缓存页面优化
@RequestMapping(value = "/toList", produces = "text/html;charset=utf-8")
@ResponseBody
public String toList_v2(Model model, User user, HttpServletRequest request, HttpServletResponse response) {
// 先从redis中拿,没有再从db中拿
String html = (String) redisTemplate.opsForValue().get("goodsList");
if (StringUtils.hasText(html)) {
return html;
}
if (null == user) { // 用户没有成功登录,或者userTicket不正确
return "login";
}
// 将用户信息放入到model中, 给模板使用
model.addAttribute("user", user);
// 从DB中拿数据
List<GoodsVo> goodsVoList = goodsService.findGoodsVo();
model.addAttribute("goodsList", goodsVoList);
// 手动渲染Template模板
WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext);
if (StringUtils.hasText(html)) {
// 每 60s 更新一次redis页面缓存, 即60s后, 该页面缓存失效, Redis 会清除该缓存
redisTemplate.opsForValue().set("goodsList", html, 60, TimeUnit.SECONDS);
}
return html;
}
商品详情页缓存(/goods//toDetail/{goodsId}) 60秒失效时间, 失效重写查询数据库, 再缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 商品详情页-redis缓存优化
@RequestMapping(value = "/toDetail/{goodsId}", produces = "text/html;charset=utf-8")
@ResponseBody
public String toGoodsDetail_v2(@PathVariable("goodsId") Long goodsId,
HttpServletRequest request, HttpServletResponse response,
Model model, User user) {
// 先从redis中拿,没有再从db中拿
String html = (String) redisTemplate.opsForValue().get("goodsDetail:" + goodsId);
if (StringUtils.hasText(html)) {
return html;
}
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
Date startTime = goodsVo.getStartDate();
Date endTime = goodsVo.getEndDate();
Date now = new Date();
// 秒杀状态 0: 未开始, 1:进行中 2:结束
Integer status = 0;
// 还剩多久秒杀开始
Long remainSeconds = 0L;
if (now.before(startTime)) {
status = 0;
remainSeconds = (startTime.getTime() - now.getTime()) / 1000;
} else if (now.after(startTime) && now.before(endTime)) {
remainSeconds = 0L;
status = 1;
} else if (now.after(endTime)) {
status = 2;
remainSeconds = -1L;
}
model.addAttribute("secKillStatus", status);
model.addAttribute("remainSeconds", remainSeconds);
model.addAttribute("goods", goodsVo);
model.addAttribute("user", user);
// 手动渲染Template模板
WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", webContext);
if (StringUtils.hasText(html)) {
// 每 60s 更新一次redis页面缓存, 即60s后, 该页面缓存失效, Redis 会清除该缓存
redisTemplate.opsForValue().set("goodsDetail:" + goodsId, html, 60, TimeUnit.SECONDS);
}
return html;
}
压测结果

如果我们商品信息修改了,会存在短时间内(比如 1 分钟), Redis 页面缓存和商品信息不一致的问题,怎么办
思路 1 : 可以将 Redis 页面缓存更新时间减小,比如设置成 10s, 甚至更短.
思路 2 : 可以在更新商品信息时,同时更新 Redis 页面缓存,台管理程序,更新 Redis 页面缓存的原理和机制是一样的
对象缓存问题
1) 当用户登录成功后就会将用户对象缓存到 Redis
2) 好处是解决了分布式架构下的 Session 共享问题
3) 但是也带来新的问题如果用户信息改变 存在 用户信息和 Redis 缓存用户对象数据不一致问题也就是对象缓存问题
解决思路
1) 编写方法 当用户信息变化时 就更新用户在 DB 的信息 同时删除该用户在 Redis 的缓存对象
2) 这样用户就需要使用新密码重新登录 从而更新用户在 Redis 对应的缓存对象.
3) 我们以修改用户密码为例来演示用户其它信息修改类似
4) 用户信息修改接口,通常是在后台系统调用的
修改密码后删除缓存, 让用重新登录, 从而刷新缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 更新密码
@Override
@Transactional(rollbackFor = Exception.class)
public ResponseBean updatePassword(User user, String password, HttpServletRequest request,
HttpServletResponse response) {
User userDb = userMapper.selectById(user.getId());
// 修改密码
String dbPass = MD5Util.inputPassToDBPass(password, userDb.getSalt());
userDb.setPassword(dbPass);
int update = userMapper.updateById(userDb);
if (update > 0) {
// 先拿到redis中userTicket作为redis的key,再删除redis中的缓存, 让用户下次重新登录
String userTicket = CookieUtil.getCookieValue(request, "userTicket");
redisTemplate.delete("user:" + userTicket);
return ResponseBean.success();
}
return ResponseBean.error(ResponseBeanEnum.UPDATE_PASSWORD_ERROR);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
*
* @param user
* @param newPassword 为了简单,直接调用的api接口, 密码不是前端加密过一次的, 而是明文
* @param request
* @param response
* @return
*/
// http://127.0.0.1/user/updatePassword?newPassword=1234
@RequestMapping("/updatePassword")
@ResponseBody
public ResponseBean updatePassword(User user, String newPassword, HttpServletRequest request, HttpServletResponse response) {
return userService.updatePassword(user, newPassword, request, response);
}
浏览器先登录一个用户, 然后调用更改密码的api接口
http://127.0.0.1/user/updatePassword?newPassword=1234
当前登录用户的密码就被改为1234了, 刷新之前的页面, 就会跳转到登录页面

6. 解决重复购买和超卖问题
秒杀基本功能中方法有些不足之处:
- 扣减库存时没有原子性, 有两条sql语句(查询, 修改, 超卖问题主要原因)
1
2
3
4
// 查询秒杀商品的库存-扣减库存
SeckillGoods seckillGoods = seckillGoodsMapper.selectById(goodsVo.getId());
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
int update = seckillGoodsMapper.updateById(seckillGoods);
现在优化为
1
2
3
4
5
6
7
8
9
10
11
12
// 优化版本的扣减库存
// mysql在默认的事务隔离级别(可重复读),update语句会在事务中锁定要更新的行 (row lock)
// 带where条件是行锁, 不带where条件是表锁(性能大幅度降低)
// 防止奇台会话在同一行执行update或者delete操作
//sql: update t_seckill_goods set stock_count = stock_count - 1 where goods_id = ? and stock_count > 0
int update = seckillGoodsMapper.update(null, new UpdateWrapper<SeckillGoods>()
.setSql("stock_count = stock_count - 1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0));
if (!(update > 0)) { // 库存不足
throw new GlobalException(ResponseBeanEnum.INSUFFICIENT_INVENTORY);
}
- 缓存秒杀订单到 Redis 中
1
2
// 将生成秒杀的订单放入的redis中, 提高复购判断时的查询效率 key=order:userId:googsId
redisTemplate.opsForValue().set("order:" + userId + ":" + goodsVo.getId(), order);
判断复购
1
2
3
4
5
6
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
.get("order:" + user.getId() + ":" + goodsVo.getId());
if (null != seckillOrder) {
model.addAttribute("errmsg", ResponseBeanEnum.LIMIT_BUY_ONE.getMessage());
return "secKillFail";
}
2.0版本的完整秒杀代码如下:
service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 需要考虑事务, 高并发
@Override
@Transactional(rollbackFor = Exception.class)
public Order seckill(User user, GoodsVo goodsVo) {
Assert.notNull(user, "用户没有登录");
Long userId = user.getId();
Assert.notNull(goodsVo, "秒杀商品goods不能为空");
// 查询秒杀商品的库存-扣减库存
// 优化版本的扣减库存-v2.0
// mysql在默认的事务隔离级别(可重复读),update语句会在事务中锁定要更新的行 (row lock)
// 防止奇台会话在同一行执行update或者delete操作
// update t_seckill_goods set stock_count = stock_count - 1 where goods_id = ? and stock_count > 0
int update = seckillGoodsMapper.update(null, new UpdateWrapper<SeckillGoods>()
.setSql("stock_count = stock_count - 1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0));
if (!(update > 0)) { // 库存不足
throw new GlobalException(ResponseBeanEnum.INSUFFICIENT_INVENTORY);
}
// 生成普通订单
Order order = new Order();
order.setUserId(userId);
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L); // 设置一个默认值
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(goodsVo.getSeckillPrice());
order.setOrderChannel((byte) 1);
order.setStatus((byte) 0);
order.setCreateDate(new Date());
this.save(order);
// 生成秒杀商品订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(userId);
seckillOrder.setGoodsId(goodsVo.getId());
seckillOrderMapper.insert(seckillOrder);
// 将生成秒杀的订单放入的redis中, 提高复购判断时的查询效率 key=order:userId:googsId
redisTemplate.opsForValue().set("order:" + userId + ":" + goodsVo.getId(), seckillOrder);
// 返回order用来回显订单信息
return order;
}
controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 处理用户抢购请求
@RequestMapping("/doSeckill")
public String doSeckill(User user, Model model, Long goodsId) {
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断是否有库存
if (goodsVo.getStockCount() < 1) {// 无库存
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 判断用户是否重复购买 redis中获取-v2.0
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
.get("order:" + user.getId() + ":" + goodsVo.getId());
if (null != seckillOrder) {
model.addAttribute("errmsg", ResponseBeanEnum.LIMIT_BUY_ONE.getMessage());
return "secKillFail";
}
// 生成订单
Order order = orderService.seckill(user, goodsVo);
if (null == order) {
model.addAttribute("errmsg", "秒杀失败, 其他原因");
return "secKillFail";
}
// 秒杀成功进入订单页面
model.addAttribute("order", order);
model.addAttribute("user", user);
model.addAttribute("goods", goodsVo);
return "orderDetail";
}
压测
准备:
清空 Redis flushdb
清空普通订单表, 秒杀订单表, 用户表中自动生成的用户
设置库存为10个
-- 压测,清理数据,避免用户限购一次的问题
delete from t_order where 1=1;
delete from t_seckill_order where 1=1;
update t_seckill_goods set stock_count = 10;
delete from seckill_user where id >= 13300000100;
重写生成用户, 并登录
执行UserUtil.main()
查看结果
-- 查看结果
create view v1 as select count(*) as '普通订单数量' from t_order;
create view v2 as select count(*) as '秒杀订单数量' from t_seckill_order;
create view v3 as select stock_count as '库存数量' from t_seckill_goods where goods_id =1;
select * from v1,v2,v3;
没有超卖问题,

压测的吞吐量为8220/sec, 线程数2000, 循环20次, 峰值时间0 ()

7. 秒杀优化:RabbitMQ + Redis
Redis 预减库存优化(v3)
我们之前防止超卖时通过到数据库查询和数据库抢购完成的
1判断库存是否充足
1
2
3
4
5
6
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断是否有库存
if (goodsVo.getStockCount() < 1) {// 无库存
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
- 通过行锁扣减库存
1
2
3
4
5
6
7
8
9
10
// mysql在默认的事务隔离级别(可重复读),update语句会在事务中锁定要更新的行 (row lock)
// 防止奇台会话在同一行执行update或者delete操作
// update t_seckill_goods set stock_count = stock_count - 1 where goods_id = ? and stock_count > 0
int update = seckillGoodsMapper.update(null, new UpdateWrapper<SeckillGoods>()
.setSql("stock_count = stock_count - 1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0));
if (!(update > 0)) { // 库存不足
throw new GlobalException(ResponseBeanEnum.INSUFFICIENT_INVENTORY);
}
这样的方式有缺点
如果在短时间, 大量抢购进入 DB, 造成峰值, 容易压垮数据库
解决方案 使用 Redis 预减库存(库存放到 Redis 中), 如果没有库存了, 直接返回, 减小对 DB 的压力(v3)
示意图

实现
- 秒杀开始前初始化库存到 Redis 中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// init()方法,在属性初始化完成后调用, 在这里初始化库存(测试方便,不要到生产中使用,因为生成环境可能有多个buy服务集群)
@Override
public void afterPropertiesSet() throws Exception {
// 查询所有秒杀商品
List<GoodsVo> list = goodsService.findGoodsVo();
// 变量商品,将上线活动的商品库存放入到redis
if (CollectionUtils.isEmpty(list)) {
return;
}
list.forEach(goodsVo -> {
Long goodsId = goodsVo.getId();
Integer stockCount = goodsVo.getStockCount();
// 商品库存key=seckillGoods:goodsId, value=库存量
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, stockCount);
// todo 需要搞个定时任务同步redis库存到数据库
});
}
Redis 预减库存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 处理用户抢购请求 v3
@RequestMapping("/doSeckill")
public String doSeckill_v3(User user, Model model, Long goodsId) {
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断是否有库存
// 既然取出了 goodsVo, 就判断一下 DB 是否还有库存, 但该方法 在大并发下, 不能确保不超卖(方法不具原子性)
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 是否重复购买
// 这个判断要在预减库存 前面, 否则如果一个用户购买成功 再次秒杀,就会预减 Redis 库存,
// 就会造成了 Redis 库存少了,但是实际 DB 库存还有剩余, 出现秒杀商品剩余的问题
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
.get("order:" + user.getId() + ":" + goodsVo.getId());
if (null != seckillOrder) {
model.addAttribute("errmsg", ResponseBeanEnum.LIMIT_BUY_ONE.getMessage());
return "secKillFail";
}
// 库存预减,如果在redis没有库存了,直接返回,不往下走了,减少执行查询数据库[优化]
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明商品已经没有库存了
// 恢复库存为0
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, 0);
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 生成订单
Order order = orderService.seckill(user, goodsVo);
if (null == order) {
model.addAttribute("errmsg", "秒杀失败, 其他原因");
return "secKillFail";
}
// 秒杀成功进入订单页面
model.addAttribute("order", order);
model.addAttribute("user", user);
model.addAttribute("goods", goodsVo);
return "orderDetail";
}
压测结果 8237, 结果不明显, 因为我的数据库在本机, Redis 在虚拟机上,(调大 Redis 的最大连接数)

内存标记优化(v4)
加入内存标记,避免总到 Reids 查询库存
1
2
3
4
5
6
7
8
// 库存预减,如果在redis没有库存了,直接返回,不往下走了,减少执行查询数据库[优化]
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明商品已经没有库存了
// 恢复库存为0
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, 0);
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
上面这段代码可以优化, 如果 Redis 中商品库存为空时, 把商品放入到map中, 再有请求过来先查本地, 如果本地有库存, 再去 Redis 扣减库存 (本地比 Redis 更快, Redis 要通过网络连接, 比较耗时)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 存放商品库存状态, key=goodsId, value: true有库存, false无库存
private ConcurrentHashMap<Long, Boolean> goodsStausMap = new ConcurrentHashMap<>();
@Override
public void afterPropertiesSet() throws Exception {
// 查询所有秒杀商品
List<GoodsVo> list = goodsService.findGoodsVo();
// 变量商品,将上线活动的商品库存放入到redis
if (CollectionUtils.isEmpty(list)) {
return;
}
list.forEach(goodsVo -> {
Long goodsId = goodsVo.getId();
Integer stockCount = goodsVo.getStockCount();
// 商品库存key=seckillGoods:goodsId, value=库存量
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, stockCount);
// 初始化库存状态
goodsStausMap.put(goodsId, stockCount > 0);
// todo 需要搞个定时任务同步redis库存到数据库
});
抢购方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 处理用户抢购请求 v4 内存标记优化(v4)
@RequestMapping("/doSeckill")
public String doSeckill_v4(User user, Model model, Long goodsId) {
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断是否有库存
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 是否重复购买 这个判断要在预减库存 前面,
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
.get("order:" + user.getId() + ":" + goodsVo.getId());
if (null != seckillOrder) {
model.addAttribute("errmsg", ResponseBeanEnum.LIMIT_BUY_ONE.getMessage());
return "secKillFail";
}
// 先在本地判断, 如果没有库存直接返回,不用走后面流程了
if (goodsStausMap.get(goodsId) != null && !goodsStausMap.get(goodsId)) {
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 库存预减,如果在redis没有库存了,直接返回,不往下走了,减少执行查询数据库[优化]
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明商品已经没有库存了
// 更新本地库存状态
goodsStausMap.put(goodsId, false);
// 恢复库存为0
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, 0);
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 生成订单
Order order = orderService.seckill(user, goodsVo);
if (null == order) {
model.addAttribute("errmsg", "秒杀失败, 其他原因");
return "secKillFail";
}
// 秒杀成功进入订单页面
model.addAttribute("order", order);
model.addAttribute("user", user);
model.addAttribute("goods", goodsVo);
return "orderDetail";
}
压测结果8330

一键清理数据方法, 清理完毕后, 直接jmeter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class TestAuto {
public static void main(String[] args) throws Exception {
Connection connection = getConn();
String sql1 = "delete from t_order where 1=1";
String sql2 = "delete from t_seckill_order where 1=1";
String sql3 = "update t_seckill_goods set stock_count = 10";
PreparedStatement ps1 = connection.prepareStatement(sql1);
ps1.executeUpdate();
PreparedStatement ps2 = connection.prepareStatement(sql2);
ps2.executeUpdate();
PreparedStatement ps3 = connection.prepareStatement(sql3);
ps3.executeUpdate();
System.out.println("清理数据库完成");
Jedis jedis = connectRedis();
jedis.set("seckillGoods:1", "10");
Set<String> keys = jedis.keys("order:*");
if (!CollectionUtils.isEmpty(keys)) {
jedis.del(keys.toArray(new String[0]));
}
System.out.println("清理redis完成");
// 重置库存状态map
// seckill/resetStockStatus
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
HttpGet httpGet = new HttpGet("http://localhost/seckill/resetStockStatus");
// 设置请求头:内容类型
httpGet.setHeader("Content-Type", "application/json;charset=UTF-8");
CloseableHttpResponse httpResponse = httpClientBuilder.build().execute(httpGet);
System.out.println("重置库存状态map完成");
// release
ps1.close();
ps2.close();
ps3.close();
jedis.close();
httpResponse.close();
System.out.println("现在可以在jmeter上压测了");
}
private static Jedis connectRedis() throws Exception {
// 连接redis
Jedis jedis = new Jedis("192.168.2.85", 6379);
return jedis;
}
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);
}
}
消息队列实现异步操作(v5)
前面秒杀, 没有实现异步机制 是完成下订单后再返回的 当有大并发请求下订单操作时数据库来不及响应容易造成线程堆积 优化方案
-
加入消息队列,实现秒杀的异步请求
-
接收到客户端秒杀请求后,服务器立即返回 正在秒杀中.., 有利于流量削峰
-
客户端进行轮询秒杀结果, 接收到秒杀结果后,在客户端页面显示即可

实现代码
使用topic交换机mq
消息体
1
2
3
4
5
6
7
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SeckillMessage implements Serializable {
private User user;
private Long goodsId;
}
mq配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 秒杀rabbitmq
private static final String SECKILL_QUEUE = "seckillQueue";
private static final String SECKILL_EXCHANGE = "seckillExchange";
private static final String SECKILL_ROUTING_KEY = "seckill.#";
@Bean
public Queue queue_seckill() {
return new Queue(SECKILL_QUEUE);
}
@Bean
public TopicExchange seckill_exchange() {
return new TopicExchange(SECKILL_EXCHANGE);
}
@Bean
public Binding binding_seckill() {
return BindingBuilder.bind(queue_seckill()).to(seckill_exchange()).with(SECKILL_ROUTING_KEY);
}
发送消息
1
2
3
4
5
6
@SneakyThrows
public void sendSeckillOrder(SeckillMessage msg) {
log.info("发送秒杀消息:{}", msg);
String msgStr = new ObjectMapper().writeValueAsString(msg);
rabbitTemplate.convertAndSend("seckillExchange", "seckill.order", msgStr);
}
接收消息
1
2
3
4
5
6
7
8
9
10
11
12
// 接收秒杀消息
@RabbitListener(queues = {"seckillQueue"})
public void recevieSeckillOrderMessage(String msg) throws JsonProcessingException {
log.info("[秒杀订单]接收到消息: {}", msg);
ObjectMapper objectMapper = new ObjectMapper();
SeckillMessage seckillMessage = objectMapper.readValue(msg, SeckillMessage.class);
// 消费消息--保存订单
User user = seckillMessage.getUser();
Long goodsId = seckillMessage.getGoodsId();
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
orderService.seckill(user, goodsVo);
}
在controller中接收到请求后, 发送秒杀消息
1
2
3
// 生成订单-发送mq消息
SeckillMessage seckillMessage = new SeckillMessage(user,goodsId);
mqSender.sendSeckillOrder(seckillMessage);
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 处理用户抢购请求 v5 消息队列
@RequestMapping("/doSeckill")
public String doSeckill_v5(User user, Model model, Long goodsId) {
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断是否有库存
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 是否重复购买 这个判断要在预减库存 前面,
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
.get("order:" + user.getId() + ":" + goodsVo.getId());
if (null != seckillOrder) {
model.addAttribute("errmsg", ResponseBeanEnum.LIMIT_BUY_ONE.getMessage());
return "secKillFail";
}
// 先在本地判断, 如果没有库存直接返回,不用走后面流程了
if (goodsStausMap.get(goodsId) != null && !goodsStausMap.get(goodsId)) {
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 库存预减,如果在redis没有库存了,直接返回,不往下走了,减少执行查询数据库[优化]
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明商品已经没有库存了
// 更新本地库存状态
goodsStausMap.put(goodsId, false);
// 恢复库存为0
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, 0);
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 生成订单-发送mq消息
SeckillMessage seckillMessage = new SeckillMessage(user,goodsId);
mqSender.sendSeckillOrder(seckillMessage);
model.addAttribute("errmsg", "排队中...");
return "secKillFail";
}
压测结果8000

注意到因为消息队列是异步, 所以无法给前端返回是否抢到的结果, 所以返回”排队中…”

这时需要客户端去轮询–抢购是否成功下单了–轮询秒杀最终结果

或者
怎么判断秒杀失败呢? 订单表中没有userId-goodsId的记录
8. 秒杀安全
秒杀接口地址隐藏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 生成唯一秒杀路径,每个用户都不一样
@Override
public String createPath(User user, Long goodsId) {
// 生成一个秒杀路径
String path = MD5Util.md5(UUIDUtil.uuid());
// key=seckillPath:userId:goodsId, value=path 过期时间60s
redisTemplate.opsForValue().set("seckillPath:" + user.getId() + ":" + goodsId, path, 60, TimeUnit.SECONDS);
return path;
}
// 校验秒杀路径
@Override
public boolean checkPath(User user, Long goodsId, String path) {
if (user == null || goodsId < 0 || !StringUtils.hasText(path)) {
return false;
}
// 校验路径
String redisPath = (String) redisTemplate.opsForValue().get("seckillPath:" + user.getId() + ":" + goodsId);
return path.equals(redisPath);
}
改造后的controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 处理用户抢购请求 v6 增加秒杀安全机制, 返回json数据, 不跳转页面了
@RequestMapping("/{path}/doSeckill")
@ResponseBody
public ResponseBean doSeckill_v6(@PathVariable("path") String path, User user, Long goodsId) {
if (null == user) {
return ResponseBean.error(ResponseBeanEnum.SESSION_ERROR);
}
// 校验路径是否合法
boolean isPathOk = orderService.checkPath(user, goodsId, path);
if (!isPathOk) {
return ResponseBean.error(ResponseBeanEnum.REQUEST_ILLEGAL);
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断是否有库存
if (goodsVo.getStockCount() < 1) {
return ResponseBean.error(ResponseBeanEnum.INSUFFICIENT_INVENTORY);
}
// 是否重复购买 这个判断要在预减库存 前面,
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
.get("order:" + user.getId() + ":" + goodsVo.getId());
if (null != seckillOrder) {
return ResponseBean.error(ResponseBeanEnum.LIMIT_BUY_ONE);
}
// 先在本地判断, 如果没有库存直接返回,不用走后面流程了
if (goodsStausMap.get(goodsId) != null && !goodsStausMap.get(goodsId)) {
return ResponseBean.error(ResponseBeanEnum.INSUFFICIENT_INVENTORY);
}
// 库存预减,如果在redis没有库存了,直接返回,不往下走了,减少执行查询数据库[优化]
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明商品已经没有库存了
// 更新本地库存状态
goodsStausMap.put(goodsId, false);
// 恢复库存为0
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, 0);
return ResponseBean.error(ResponseBeanEnum.INSUFFICIENT_INVENTORY);
}
// 生成订单-发送mq消息
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
mqSender.sendSeckillOrder(seckillMessage);
return ResponseBean.success(ResponseBeanEnum.SECKILL_QUEUE);
}
前端页面
1
2
3
4
5
<!--对应秒杀v6.0后台 隐藏秒杀接口-->
<input type="hidden" id="goodsId" name="goodsId" th:value="${goods.id}">
<button class="btn btn-primary btn-block" type="submit" onclick="getSecKillPath()" id="buyButton">
立即秒杀
</button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//通过隐藏秒杀接口的方式,秒杀商品
function getSecKillPath() {
var goodsId = $("#goodsId").val();
//获取用户输入的验证码
// var captcha = $("#captcha").val();
$.ajax({
url: "/seckill/path",
type: "GET",
data: {
goodsId: goodsId,
// captcha: captcha,
},
success: function (data) {
if (data.code == 200) {
//取出服务器返回的随机生成的路径
var path = data.obj;
//真正的秒杀方法
doSecKill(path);
} else {
layer.msg(data.message);
}
},
error: function () {
layer.msg("客户端请求错误");
}
})
}
//携带秒杀生成的路径, 秒杀商品
function doSecKill(path) {
$.ajax({
//一定要注意路径的大小写问题,否则报404
url: '/seckill/' + path + '/doSeckill',
type: 'POST',
data: {
goodsId: $("#goodsId").val()
},
success: function (data) {
if (data.code == 200) {
layer.msg("秒杀结果" + data.message);
} else {
layer.msg(data.message);
}
},
error: function () {
layer.msg("客户端请求错误!", {time: 2000});
}
});
}

验证码防止脚本攻击
在一些抢购活动中 可以通过验证码的方式 防止脚本攻击 比如 12306
使用验证码工具: happyCaptcha https://gitee.com/ramostear/Happy-Captcha

生成验证码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 生成验证码,并放到redis中
@RequestMapping("/captcha")
public void happyCaptcha(HttpServletRequest request, HttpServletResponse response, User user, Long goodsId) {
// 生成验证码并返回, 验证码默认就保存在session中, sessionKey: happy-captcha
HappyCaptcha.require(request, response)
.style(CaptchaStyle.ANIM) // 设置展现样式为动画
.type(CaptchaType.NUMBER) // 设置验证码内容为数字
.length(6) // 设置字符长度为6
.width(220) // 设置动画宽度为220
.height(80) // 设置动画高度为80
.font(Fonts.getInstance().zhFont()) // 设置汉字的字体
.build().finish(); // 生成并输出验证码
// 验证码保存在redis中, 时间为5min, key: captcha:userId:goodsId
redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId,
request.getSession().getAttribute("happy-captcha"), 300, TimeUnit.SECONDS);
}
校验验证码方法
1
2
3
4
5
6
7
8
@Override
public boolean checkCode(User user, Long goodsId, String code) {
if (user == null || goodsId < 0 || !StringUtils.hasText(code)) {
return false;
}
String redisCode = (String) redisTemplate.opsForValue().get("captcha:" + user.getId() + ":" + goodsId);
return code.equals(redisCode);
}
在获取隐藏的秒杀路径时校验验证码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 秒杀安全-获取秒杀路径
@RequestMapping("/path")
@ResponseBody
public ResponseBean getPath(User user, Long goodsId, String captcha) {
if (user == null || goodsId == null || goodsId < 0) {
return ResponseBean.error(ResponseBeanEnum.SESSION_ERROR);
}
// 校验验证码
boolean isCodeOK = orderService.checkCode(user, goodsId, captcha);
if (!isCodeOK) {
return ResponseBean.error(ResponseBeanEnum.CODE_ERROR);
}
String path = orderService.createPath(user, goodsId);
return ResponseBean.success(path);
}
1
2
3
4
5
6
7
8
9
<td>
<!--显示验证码-->
<img id="captchaImg" class="captchaImg"/>
</td>
<td>
<!--输入框,可以输入验证码-->
<input id="captcha" class="captchaImg"/>
<input type="button" id="captchabtn" onclick="verifyCaptcha()" value="验证输入的验证码是否正确">
</td>
1
2
3
4
5
// 在秒杀进行中的状态下, 请求验证码,并显示(默认情况下该验证码图片是不显示的)
$("#captchaImg").attr("src", "/seckill/captcha?goodsId=" + $("#goodsId").val());
$("#captchaImg").show();
// 显示输入框
$("#captcha").show();
获取隐藏路径时传递验证码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//通过隐藏秒杀接口的方式,秒杀商品
function getSecKillPath() {
var goodsId = $("#goodsId").val();
//获取用户输入的验证码
var captcha = $("#captcha").val();
$.ajax({
url: "/seckill/path",
type: "GET",
data: {
goodsId: goodsId,
captcha: captcha,
},
success: function (data) {
if (data.code == 200) {
//取出服务器返回的随机生成的路径
var path = data.obj;
//真正的秒杀方法
doSecKill(path);
} else {
layer.msg(data.message);
}
},
error: function () {
layer.msg("客户端请求错误");
}
})
}

秒杀接口限流防刷
接口限流-防止某个用户频繁的请求秒杀接口
点击过于频繁后给用户显示: “访问过于频繁, 请稍后再试”

Redis 计数器, 完成接口限流
- 除了计数器算法,也有其它的算法来进行接口限流,比如漏桶算法和令牌桶算法
令牌桶算法,相对比较主流
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 秒杀安全-获取秒杀路径-v7
@RequestMapping("/path")
@ResponseBody
public ResponseBean getPath(User user, Long goodsId, String captcha, HttpServletRequest request) {
if (user == null || goodsId == null || goodsId < 0) {
return ResponseBean.error(ResponseBeanEnum.SESSION_ERROR);
}
// v8秒杀计数器,防止用户刷接口, 5s内超过5次就认为是刷接口
String uri = request.getRequestURI();
Integer count = (Integer) redisTemplate.opsForValue().get(uri + ":" + user.getId());
if (count == null) { // 说明是第一次访问
redisTemplate.opsForValue().set(uri + ":" + user.getId(), 1, 5, TimeUnit.SECONDS);
} else if (count != null && count < 5) {
redisTemplate.opsForValue().increment(uri + ":" + user.getId());
} else if (count != null && count >= 5) {
return ResponseBean.error(ResponseBeanEnum.LIMIT_ACCESS);
}
// 校验验证码 -v7
boolean isCodeOK = orderService.checkCode(user, goodsId, captcha);
if (!isCodeOK) {
return ResponseBean.error(ResponseBeanEnum.CODE_ERROR);
}
String path = orderService.createPath(user, goodsId);
return ResponseBean.success(path);
}

上面代码缺点: 侵入性太强, 过期时间/数量(即访问速率)写死了
限流代码改进, 使用注解实现

实现
1
2
3
4
5
6
7
8
9
10
11
// 在统计时间长度内,达到最大访问次数,就会限制访问
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessRateLimiter {
// 统计时间长度
int second();
// 最大访问次数
int maxCount();
// 是否需要登录,默认需要登录
boolean needLogin() default true;
}
1
2
3
4
5
6
7
8
9
10
11
12
public class UserContext {
// 每个线程都有自己的ThreadLocal, 把共享数据放在ThreadLocal,保证线程安全
private static ThreadLocal<User> userHolder = new ThreadLocal<>();
public static void setUser(User user) {
userHolder.set(user);
}
public static User getUser(User user) {
return userHolder.get();
}
}
拦截器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Component
@SuppressWarnings("all")
public class AccessLimitInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private RedisTemplate redisTemplate;
// 实现两个功能: 得到user对象,放入到ThreadLocal, 解析注解@AccessRateLimiter
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
User user = getUser(request, response);
// 得到user对象,放入到ThreadLocal
UserContext.setUser(user);
HandlerMethod hm = (HandlerMethod) handler;
AccessRateLimiter rateLimiter = hm.getMethodAnnotation(AccessRateLimiter.class);
if (rateLimiter == null) {
// 没有限流注解, 直接放行
return true;
}
int maxCount = rateLimiter.maxCount();
int second = rateLimiter.second();
boolean isNeedLogin = rateLimiter.needLogin(); // 是否需要登录
if (isNeedLogin) {
// 需要登录
if (user == null) { // 没有登录时, 就重定向到登录页面去, 返回false表示不走下面的流程了
render(response, ResponseBeanEnum.SESSION_ERROR);
return false;
}
}
// 进行访问速度限制: 在一定的时间内超过固定的访问次数,就进行限流-熔断
String uri = request.getRequestURI();
Integer count = (Integer) redisTemplate.opsForValue().get(uri + ":" + user.getId());
if (count == null) { // 说明是第一次访问
redisTemplate.opsForValue().set(uri + ":" + user.getId(), 1, second, TimeUnit.SECONDS);
} else if (count != null && count < maxCount) {
redisTemplate.opsForValue().increment(uri + ":" + user.getId());
} else if (count != null && count >= maxCount) {
render(response,ResponseBeanEnum.LIMIT_ACCESS);
return false;
}
}
return true;
}
@SneakyThrows
private void render(HttpServletResponse response, ResponseBeanEnum responseBeanEnum) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
ResponseBean responseBean = ResponseBean.error(responseBeanEnum);
response.getWriter().write(new ObjectMapper().writeValueAsString(responseBean));
}
private User getUser(HttpServletRequest request, HttpServletResponse response) {
String userTicket = CookieUtil.getCookieValue(request, "userTicket");
if (!StringUtils.hasText(userTicket)) {
return null; // 用户没有登录
}
return userService.getUserByCookie(userTicket, request, response);
}
}
注册拦截器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Autowired
private UserArgumentResolver userArgumentResolver;
@Autowired
private AccessLimitInterceptor accessLimitInterceptor;
// 静态资源加载
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
// 自定义参数解析
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
// 自定义拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(accessLimitInterceptor);
}
}
使用注解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 秒杀安全-获取秒杀路径-v7
@RequestMapping("/path")
@ResponseBody
@AccessRateLimiter(second = 5, maxCount = 5, needLogin = true)
public ResponseBean getPath(User user, Long goodsId, String captcha, HttpServletRequest request) {
if (user == null || goodsId == null || goodsId < 0) {
return ResponseBean.error(ResponseBeanEnum.SESSION_ERROR);
}
// v8秒杀计数器,防止用户刷接口, 5s内超过5次就认为是刷接口 -被注解替代
// String uri = request.getRequestURI();
// Integer count = (Integer) redisTemplate.opsForValue().get(uri + ":" + user.getId());
// if (count == null) { // 说明是第一次访问
// redisTemplate.opsForValue().set(uri + ":" + user.getId(), 1, 5, TimeUnit.SECONDS);
// } else if (count != null && count < 5) {
// redisTemplate.opsForValue().increment(uri + ":" + user.getId());
// } else if (count != null && count >= 5) {
// return ResponseBean.error(ResponseBeanEnum.LIMIT_ACCESS);
// }
// 校验验证码 -v7
boolean isCodeOK = orderService.checkCode(user, goodsId, captcha);
if (!isCodeOK) {
return ResponseBean.error(ResponseBeanEnum.CODE_ERROR);
}
String path = orderService.createPath(user, goodsId);
return ResponseBean.success(path);
}
参数解析器优化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
@Autowired
private UserService userService;
//如果这个方法返回 true 才会执行下面的 resolveArgument 方法
// 返回 false 不执行下面的方法
@Override
public boolean supportsParameter(MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();
return User.class == parameterType;
}
// * 这个方法,类似拦截器,将传入的参数,取出 cookie 值,然后获取对应的 User 对象
// * 并把这个 User 对象作为参数继续传递.
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
// HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
// HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
// String userTicket = CookieUtil.getCookieValue(request, "userTicket");
// if (!StringUtils.hasText(userTicket)) {
// return null;
// }
// //根据 cookie-ticket 到 Redis 获取 User
// User user = userService.getUserByCookie(userTicket, request, response);
// 通过线程上下文获取
return UserContext.getUser();
}
}
测试是OK的, 注解可以达到功能
9. Redis 分布式锁
预防超卖的方法 (decrement具有原子性)
-
decrement 方法具有原子性和隔离性所以有效的控制了抢购
-
所以在本项目中不使用 Redis 分布式锁 也是可以控制抢购不出现 超购和复购
1. 对于当前项目,使用 redisTemplate.opsForValue().decrement()就可以控制 2. 考虑到如果有比较多的操作,需要保证隔离性, 和对 Redis 操作也不是简单的-1,而是有 多个操作, 就需要使用 Redis 分布式锁来控制
1
2
3
4
5
6
7
8
9
10
// 库存预减,如果在redis没有库存了,直接返回,不往下走了,减少执行查询数据库[优化]
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明商品已经没有库存了
// 更新本地库存状态
goodsStausMap.put(goodsId, false);
// 恢复库存为0
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, 0);
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
MySQL 使用行锁
1
2
3
4
5
6
7
8
9
10
// mysql在默认的事务隔离级别(可重复读),update语句会在事务中锁定要更新的行 (row lock)
// 防止奇台会话在同一行执行update或者delete操作
// update t_seckill_goods set stock_count = stock_count - 1 where goods_id = ? and stock_count > 0
int update = seckillGoodsMapper.update(null, new UpdateWrapper<SeckillGoods>()
.setSql("stock_count = stock_count - 1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0));
if (!(update > 0)) { // 库存不足
throw new GlobalException(ResponseBeanEnum.INSUFFICIENT_INVENTORY);
}
1
2
3
开发中, 我们业务可能比较复杂综合, 不是一个redis操作(decrement)就可以完成的, 比 如还需要进行修改操作(set), 甚至还会操作 DB, 文件, 第三方数据源等.
- 这时我们就需要扩大代码隔离性范围, 可以考虑使用 Redis 分布式锁
回来看为什么会有库存遗留问题!!!! –因为释放锁的代码放的位置错误了(放在预扣减库存的代码中了)
实现代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Autowired
private RedisScript redisScript;
// 和v5并行版本-但是预减库存采用分布式锁
// 处理用户抢购请求 v51 分布式锁--性能优化完结-之后版本是安全优化
@RequestMapping("/doSeckill")
public String doSeckill_v51(User user, Model model, Long goodsId) {
if (null == user) {
return "login";
}
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断是否有库存
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 是否重复购买 这个判断要在预减库存 前面,
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
.get("order:" + user.getId() + ":" + goodsVo.getId());
if (null != seckillOrder) {
model.addAttribute("errmsg", ResponseBeanEnum.LIMIT_BUY_ONE.getMessage());
return "secKillFail";
}
// 先在本地判断, 如果没有库存直接返回,不用走后面流程了
if (goodsStausMap.get(goodsId) != null && !goodsStausMap.get(goodsId)) {
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 库存预减-分布式锁版本,如果在redis没有库存了,直接返回,不往下走了,减少执行查询数据库[优化]
String uuid = UUID.randomUUID().toString();
// 分布式锁, key=lock,value=uuid(自己的锁只有自己能删除), 过期时间3秒
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
if (lock) { // 获取锁成功
// 处理业务
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明商品已经没有库存了
// 更新本地库存状态
goodsStausMap.put(goodsId, false);
// 恢复库存为0
redisTemplate.opsForValue().set("seckillGoods:" + goodsId, 0);
model.addAttribute("errmsg", ResponseBeanEnum.INSUFFICIENT_INVENTORY.getMessage());
return "secKillFail";
}
// 释放-lock,只有获取的锁和自己锁是同一把锁才删除
Long result = (Long) redisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
if (result.equals(1L)) {
log.info("释放锁成功");
}
} else {
// 获取锁失败、返回信息, 这次抢购失败,请继续抢购
model.addAttribute("errmsg", ResponseBeanEnum.SEC_KILL_RETRY.getMessage());
return "secKillFail";
}
// 生成订单-发送mq消息
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
mqSender.sendSeckillOrder(seckillMessage);
model.addAttribute("errmsg", "排队中...");
return "secKillFail";
}
redisConfig
1
2
3
4
5
6
7
8
// lua脚本预加载, 在controller中注入private RedisScript redisScript;
@Bean
public DefaultRedisScript<Long> script() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("lock.lua"));
redisScript.setResultType(Long.class);
return redisScript;
}
原子性删除锁的lua脚本
1
2
3
4
5
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
END