# codefish-seckill
**Repository Path**: yankeyong/codefish-seckill
## Basic Information
- **Project Name**: codefish-seckill
- **Description**: 秒杀系统设计,主要是为了熟悉后端框架(SpringBoot)的API以及了解Redis和RabbitMQ的使用场景
- **Primary Language**: Java
- **License**: Apache-2.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 1
- **Created**: 2025-08-09
- **Last Updated**: 2025-08-09
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# codefih-seckill
## 一、 项目概述
对于双十一、618等时候,系统的并发量会大幅度增加,这时就需要设计一个能够应对高并发场景的秒杀系统。
## 二、 需要解决的问题
- **高并发读**: 尽量减少用户直接从数据库读取数据的频率,或者让他们读取更少的数据。
- **高并发写**: 限制写入速率,数据库读写分离,保证一致性等等。
## 三、 系统设计目标
1. **高性能:** 秒杀设计大量的并发读和并发写,因此支持高并发访问十分关键,对应的解决方案有动静分离,热点的发现与隔离,请求削峰与过滤,服务端极致优化。
2. **一致性:** 需要保证库存的一致性,不会出现超卖的情况。
3. **高可用:** 系统需要有PlanB来兜底,来应对最坏情况的发生。
## 四、 使用到的技术点(框架)
### 前端技术栈:
|名称|功能|官网地址|文档地址|第三方中文文档地址|
|---|---|---|---|---|
|Tymeleaf|前端页面模板引擎|https://www.thymeleaf.org/|https://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf.html|/|
|BootStrap|css样式、组件库|https://getbootstrap.com/|https://getbootstrap.com/docs/5.2/https://v5.bootcss.com/docs/5.1/getting-started/introduction/|
|Jquery|js库,简化dom和提供ajax支持|https://jquery.com/|https://api.jquery.com/|https://jquery.cuishifeng.cn/index.html|
### 后端技术栈:
|名称|功能|官网地址|文档地址|第三方中文文档地址|
|---|---|---|---|---|
|SpringBoot|提供各种starter支持|https://spring.io/|https://spring.io/projects/spring-boot|https://springref.com/projects/spring-boot|
|MybatisPlus|orm框架|https://baomidou.com/|https://baomidou.com/pages/24112f/|/|
|Lombok|简化java bean对象的开发|https://projectlombok.org/|https://objectcomputing.com/resources/publications/sett/january-2010-reducing-boilerplate-code-with-project-lombok|/|
|Spring Security|权限框架|https://spring.io/|https://spring.io/projects/spring-security|https://springref.com/projects/spring-security|
### 中间件:
|名称|功能|官网地址|文档地址|第三方中文文档地址|
|---|---|---|---|---|
|Redis|全局数据缓存|https://redis.io/|https://redis.io/docs/|/|
|RabbitMQ|消息队列,流量削峰、模块异步解耦等|https://www.rabbitmq.com/|https://www.rabbitmq.com/documentation.html|/|
## 五. 方案设计
### 5.1 用户登录
用户的登录以及身份的校验采用`Token + Redis`来实现,并且借助`Spring Security`框架来实现登录认证。这样设计的原因是可以通过Redis来实现分布式session,
保证用户的请求在集群中的任何一台服务器上都能被识别。
一、用户首次登录流程
1. 用户在登录页面输入账号密码,点击登录。
2. 后端校验用户名密码的正确性。
3. 若校验失败,返回失败提示信息到前端。
4. 若校验成功,则生成token存储到cookie中,并且以token为key将用户信息存储到redis中(设置过期时间)。
二、用户身份验证流程
1. 用户访问某个需要验证身份的网站。
2. 请求经过过滤器。
3. 过滤器拿到cookie中的token,并通过token去redis查询用户信息。
4. 若用户信息合法,放行请求。
5. 若不合法,返回拒绝访问提示信息到前端。
### 5.2 商品信息业务
一、商品信息查询(以商品id作为查询条件)
1. 将用户请求的商品id拼接成对应的redis key并查询redis。
2. 若redis中存在则返回商品信息。
3. 若不存在,则去数据库查询。
4. 获得查询结果,返回结果并存入redis。
二、商品信息增加(以商品id作为查询条件)
缓存懒加载策略,添加商品信息后无需预先存入redis,而是等待用户第一次对商品的查询请求发生,此时商品必然被存入redis。
三、商品信息删除(以商品id作为查询条件)
数据库删除,redis缓存延时双删。
四、商品信息更新(以商品id作为查询条件)
数据库更新,redis缓存延时双删。
### 5.3 秒杀业务
> 这里秒杀设计了三种方案(解决了超卖)。
一、借助数据库的锁机制
InnoDB事务在更新数据时会对数据行加上行级写锁(X锁),直到事务提交或回滚后锁才会被释放;
当数据行被某个事务加上写锁后,其它尝试读取或更新该数据行的事务会被阻塞;
根据这一悲观锁的性质,我们可以在将数据库中商品库存减1前判断商品库存是否大于0;可行的Mapper实现如下:
```xml
update sk_sec_kill_goods
set goods_count = goods_count - #{stock}
where goods_id = #{goodsId} and goods_count > 0;
```
更新语句的where条件里判断`goods_count > 0`,配合锁机制可以解决库存超卖问题。
二、借助redis的单线程特性(redis预减库存)
1. redis的一大特性就是通过`IO多路复用 + 单线程轮询`来处理各种客户端的请求。`单线程`决定了redis的**单个**指令操作都满足`原子性`,也就是对某个指令的执行必定是连续的。
2. redis的`decr`指令可以实现`自减1`操作,当然,这满足原子性;redis的`decr`指令使用示例:
```
127.0.0.1:6379> set hh 5
OK
127.0.0.1:6379> decr hh
(integer) 4
127.0.0.1:6379> get hh
"4"
```
三、lua脚本
redis对单个lua脚本的执行也满足原子性,因此如果需要对实现多个操作的原子特性,那么就需要去编写lua脚本。
> 该项目中使用的lua脚本代码如下:
``` lua
--redis 减库存lua脚本
if (redis.call("exists", KEYS[1]) == 1) then
local stock = tonumber(redis.call("get", KEYS[1]))
if (stock > 0) then
redis.call("incrby", KEYS[1], -1)
return stock - 1
end
return 0
end
```
四、基于Spring Security实现身份组秒杀鉴权
总的来说就是将用户分为`普通用户`、`黄金用户`和`钻石用户`,并且数据库会维护某个商品可以被哪些等级的用户秒杀。
然后使用Spring Security框架,基于注解对秒杀入口进行AOP代理,在秒杀前会判断用户是否有秒杀权限。实现例子如下:
```java
//Spring Security提供,实现权限校验
@PreAuthorize("@ps.hasPermission(#goodsId)")
@PostMapping("/{path}/doSeckill")
@Transactional
public String doSeckill(@RequestParam Long goodsId, Model model, HttpSession session, @PathVariable(name = "path") String path
, @CookieValue(name = ResourceController.CAPTCHA_COOKIE_KEY) String captchaToken
, @RequestParam String captcha){
//业务代码省略
//-----------
//-------------
}
```
### 5.4 订单业务
一、订单添加入库
> 这里借助了`RabbitMQ`来实现订单信息入库。解耦订单生成模块和订单入库模块。订单入库模块以1次/s的速率消费消息。从而减小数据库写入压力。
1. 当秒杀成功后,生成秒杀订单信息并且发生到订单消息队列中。
2. 订单消息队列的消费者拿到消息后解析成order对象并且持久化到数据库。
3. 消费者线程休眠1s。随后进入下一轮的消费操作。
### 5.5 其它功能——项目秒杀优化
一、分布式锁
为了保证秒杀系统的高可用性,我们希望能够将该系统集群部署,此时线程并发操作,synchronized和Lock包下的锁就失去了作用。
其原因是这些锁都是本地锁,被封闭在一个JVM进程里。因此就需要支持跨进程同步的分布式锁。
**实现思路**
对于减库存操作,除了数据库的写锁,当然也可以使用分布式锁。本项目的分布式锁基于redis指令操作的原子性实现。
redis的`setnx`指令的作用是当key不存在时设置key-value,使用示例如下:
```
127.0.0.1:6379> setnx lock abc
(integer) 1
127.0.0.1:6379> setnx lock bbb
(integer) 0
127.0.0.1:6379> get lock
"abc"
```
借助这条指令可以设计这样的加锁逻辑:
> 1. 线程尝试获取锁时,调用`setnx `往redis设值。
> 2. 如果成功,返回`true`,标识加锁成功。
> 3. 如果失败,返回`false`,表示加锁失败。
当然,还需要考虑一些细节问题:如果执行进行解锁操作时进程挂了,那么就会导致解锁不成功,后续线程进行`setnx`操作都将失败。这个锁永远无法释放。
关于进程挂掉导致锁无法释放的问题,有一个很简单的解决方案:
> 对锁设置失效时间,超时则自动释放。
对于解锁,我们很容易想到的就是直接将锁对应的key从redis中清除,即:
> 调用删除key的API,执行 del 操作
这个解锁操作看似合情合理,但是由于key被设了过期时间,如果不检查锁的持有者就直接删除key,会导致其它线程获得的锁被误解锁,造成线程安全问题。
例如下面的情况:
```
线程t1:获取锁 ——> 获取成功 ——> 执行同步块代码时间超过锁最大失效时间 ——>锁失效 ————————>业务执行完,执行解锁操作————>锁被删除
线程t2: 获取锁 ——>获取失败,自旋获取锁———————————————————————————>获取成功 ——>执行业务代码——————————————————> t2的锁被t1误删除
```
这种情况是不能接受的,解决方案为:
> 1. 加锁时设置锁的标识
> 2. 解锁时判断锁的标识是否和自己加锁时设置的标识一致
> 3. 一致则删除key
> 4. 不一致则放弃删除key
上述操作需要两条指令:
```
get
(如果value和加锁时设置的标识一致)
del
```
redis只能保证单条指令的原子性,因此需要借助lua脚本来实现解锁操作原子性:
```lua
--redis实现分布式锁的解锁
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
```
最终一个分布式锁的代码实现如下:
```java
package com.codefish.codefishseckill.utils.lock;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.LockSupport;
/**
* 基于redis的setnx指令实现的分布式锁
*
* @author codefish
* @version 1.0.0
* @since 2022/09/02 下午 02:32
*/
public class RedisDistributedLock implements Lock {
//redis操作对象
RedisTemplate redisTemplate;
//分布式锁的名称,不指定名称则通过uuid生成
private final String redisLockName;
//分布式锁在redis中的前缀
private static final String REDIS_KEY_PREFIX = "distributedLock:";
//默认键过期时间
private static final Long DEFAULT_LOCK_TTL_MILLIS = 6000L;
//lua脚本
private static final DefaultRedisScript UNLOCK_SCRIPT;
//存储线程当前生成的token
private static final ThreadLocal LOCK_TOKEN = ThreadLocal.withInitial(() -> UUID.randomUUID().toString());
//初始化解锁的lua脚本
static {
String luaCode = "if redis.call(\"get\", KEYS[1]) == ARGV[1] then\n" +
" return redis.call(\"del\", KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setScriptText(luaCode);
UNLOCK_SCRIPT.setResultType(Boolean.class);
}
public RedisDistributedLock(RedisTemplate redisTemplate, String redisLockName) {
if (StringUtils.isEmpty(redisLockName)) {
throw new IllegalArgumentException("redis分布式锁的名称不能为空");
}
this.redisTemplate = redisTemplate;
this.redisLockName = redisLockName;
}
public RedisDistributedLock(RedisTemplate redisTemplate) {
//未指定锁的名称,通过uuid随机生成锁的名称
this(redisTemplate, UUID.randomUUID().toString());
}
/**
* 加锁,尝试加锁失败则自旋,直到获取锁成功
*/
@Override
public void lock() {
while (!tryLock()) {
//获取锁失败则自旋
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//跳出while循环,加锁成功
}
/**
* 解锁
*/
@Override
public void unlock() {
delIfEqual(LOCK_TOKEN.get());
LOCK_TOKEN.remove();
}
/**
* 尝试加锁
*
* @return 加锁是否成功
*/
@Override
public boolean tryLock() {
return setNx(LOCK_TOKEN.get());
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
throw new UnsupportedOperationException();
}
@Override
public void lockInterruptibly() throws InterruptedException {
throw new UnsupportedOperationException();
}
@Override
public Condition newCondition() {
throw new UnsupportedOperationException();
}
// -------------- redis分布式锁相关的逻辑操作 --------------------
private String getRedisKeyByName() {
return REDIS_KEY_PREFIX + redisLockName;
}
private boolean setNx(String token) {
return redisTemplate.opsForValue().setIfAbsent(getRedisKeyByName(), token, DEFAULT_LOCK_TTL_MILLIS, TimeUnit.MILLISECONDS);
}
/**
* unlock逻辑执行lua脚本,保证解锁操作的原子性
*
* @param token 当前线程的token
*/
private void delIfEqual(String token) {
redisTemplate.execute(UNLOCK_SCRIPT, Arrays.asList(getRedisKeyByName()), token);
}
}
```
分布式锁测试代码:
```java
@SpringBootTest
class CodefishSeckillApplicationTests {
@Autowired
RedisTemplate redisTemplate;
@Test
public void redisLockTest() throws Exception {
RedisDistributedLock lock = new RedisDistributedLock(redisTemplate, "demoLock");
Thread t1 = new Thread(() -> {
lock.lock();
try {
System.out.println("t1 in");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
});
Thread t2 = new Thread(() -> {
lock.lock();
try {
System.out.println("t2 in");
} finally {
lock.unlock();
}
});
t1.start();
Thread.sleep(200);
t2.start();
t1.join();
t2.join();
}
}
```
二、实现可重入的分布式锁
上面实现的锁存在一个问题,就是没有实现`可重入锁`。
为了实现锁的可重入,我们需要用redis保存锁的state。参考`ReentrantLock`对锁可重入的实现:
```java
/**
* Performs non-fair tryLock. tryAcquire is implemented in
* subclasses, but both need nonfair try for trylock method.
*/
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
```
我们可以编写分布式锁的加锁和解锁脚本如下:
```lua
-- redis实现分布式锁的加锁操作
-- 变量声明部分 -------
local lockKey = KEYS[1]
local stateKey = KEYS[2]
local tryToken = ARGV[1]
local expireSeconds = ARGV[2]
-- 逻辑部分 ---------
if redis.call("set", lockKey, tryToken, "EX", expireSeconds, 'NX') then
-- 初次获取锁成功
redis.call("setex", stateKey, expireSeconds, 1)
return true
end
-- 判断是否可重入
if redis.call("get", lockKey) == tryToken then
-- 重入锁成功
redis.call("incr", stateKey)
return true
end
-- 锁不是自己的,重入失败
return false
```
```lua
--redis实现分布式锁的解锁
-- 变量声明部分 -------
local lockKey = KEYS[1]
local stateKey = KEYS[2]
local tryToken = ARGV[1]
-- 逻辑部分 ----------
if redis.call("get", lockKey) == tryToken then
-- 该锁被当前线程持有
if redis.call("decr", stateKey) == 0 then
redis.call("del", lockKey)
redis.call("del", stateKey)
-- 返回true表示锁被完全释放
return true
end
end
-- 返回false,表示解锁失败或者锁未完全释放
return false
```
以及修改后的`RedisDistributedLock`代码:
```java
package com.codefish.codefishseckill.utils.lock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
/**
* 基于redis的setnx指令实现的分布式锁
*
* @author codefish
* @version 1.0.0
* @since 2022/09/02 下午 02:32
*/
@Slf4j
public class RedisDistributedLock implements Lock {
//redis操作对象
RedisTemplate redisTemplate;
//分布式锁的名称,不指定名称则通过uuid生成
private final String redisLockName;
//分布式锁在redis中的前缀
private static final String REDIS_KEY_PREFIX = "distributedLock:";
//锁重入次数计数前缀
private static final String REDIS_KEY_STATE_PREFIX = "distributedLock:state:";
//默认键过期时间
private static final Integer DEFAULT_LOCK_TTL_SECONDS = 30;
//lua脚本
private static final DefaultRedisScript UNLOCK_SCRIPT = new DefaultRedisScript<>();
private static final DefaultRedisScript LOCK_SCRIPT = new DefaultRedisScript<>();
//存储线程当前生成的token
private static final ThreadLocal LOCK_TOKEN = ThreadLocal.withInitial(() -> UUID.randomUUID().toString());
//初始化lua脚本
static {
//加载资源
ClassPathResource lockLuaResource = new ClassPathResource("lock.lua");
ClassPathResource unlockLuaResource = new ClassPathResource("unlock.lua");
String lockStr;
String unlockStr;
byte[] buffer = new byte[5 * 1024];
int len;
try (InputStream lockInput = lockLuaResource.getInputStream();
InputStream unlockInput = unlockLuaResource.getInputStream()) {
len = lockInput.read(buffer);
lockStr = new String(buffer, 0, len, StandardCharsets.UTF_8);
len = unlockInput.read(buffer);
unlockStr = new String(buffer, 0, len, StandardCharsets.UTF_8);
//初始化加锁的lua脚本
LOCK_SCRIPT.setScriptText(lockStr);
LOCK_SCRIPT.setResultType(Boolean.class);
//初始化解锁的lua脚本
UNLOCK_SCRIPT.setScriptText(unlockStr);
UNLOCK_SCRIPT.setResultType(Boolean.class);
} catch (IOException e) {
log.error("初始化lua脚本失败");
e.printStackTrace();
}
}
public RedisDistributedLock(RedisTemplate redisTemplate, String redisLockName) {
if (StringUtils.isEmpty(redisLockName)) {
throw new IllegalArgumentException("redis分布式锁的名称不能为空");
}
this.redisTemplate = redisTemplate;
this.redisLockName = redisLockName;
}
public RedisDistributedLock(RedisTemplate redisTemplate) {
//未指定锁的名称,通过uuid随机生成锁的名称
this(redisTemplate, UUID.randomUUID().toString());
}
/**
* 加锁,尝试加锁失败则自旋,直到获取锁成功
*/
@Override
public void lock() {
while (!tryLock()) {
//获取锁失败则自旋
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//跳出while循环,加锁成功
}
/**
* 解锁
*/
@Override
public void unlock() {
if (doUnlock(LOCK_TOKEN.get())) {
LOCK_TOKEN.remove();
}
}
/**
* 尝试加锁
*
* @return 加锁是否成功
*/
@Override
public boolean tryLock() {
return doLock(LOCK_TOKEN.get());
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
throw new UnsupportedOperationException();
}
@Override
public void lockInterruptibly() throws InterruptedException {
throw new UnsupportedOperationException();
}
@Override
public Condition newCondition() {
throw new UnsupportedOperationException();
}
// -------------- redis分布式锁相关的逻辑操作 --------------------
private String getRedisKey() {
return REDIS_KEY_PREFIX + redisLockName;
}
private String getRedisStateKey() {
return REDIS_KEY_STATE_PREFIX + redisLockName;
}
private boolean doLock(String token) {
return redisTemplate.execute(LOCK_SCRIPT, Arrays.asList(getRedisKey(), getRedisStateKey()), token, DEFAULT_LOCK_TTL_SECONDS);
}
/**
* unlock逻辑执行lua脚本,保证解锁操作的原子性
*
* @param token 当前线程的token
*/
private boolean doUnlock(String token) {
return redisTemplate.execute(UNLOCK_SCRIPT, Arrays.asList(getRedisKey(), getRedisStateKey()), token);
}
}
```
三、动态秒杀地址
四、验证码
五、秒杀接口限流
## 特技
1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)