# 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/)