redis-practice 是一个可直接运行的 Redis 实战项目,使用 Spring Boot 3、MyBatis-Plus、MySQL 和 Redis 实现商品查询、普通订单和秒杀订单。项目覆盖 String、Hash、List、Set、ZSet、Cache Aside、缓存穿透、缓存击穿、缓存雪崩、Redisson 分布式锁、Lua 幂等校验、事务后删缓存以及数据库条件扣库存。
所有接口统一返回:
{
"code": 200,
"message": "success",
"data": {},
"timestamp": 1710000000000
}- Java 17
- Spring Boot 3.3.5
- Maven
- MyBatis-Plus 3.5.7
- MySQL 8
- Redis 7
- Spring Data Redis / Redisson
- Lombok / Jakarta Validation / Jackson
- JUnit 5 / Spring Boot Test / Mockito
redis-practice
├── pom.xml
├── docker-compose.yml
├── README.md
├── src/main/java/com/wang/redispractice
│ ├── common # 统一响应、响应码、Redis Key
│ ├── config # Redis、Redisson、MyBatis-Plus、Jackson
│ ├── controller # HTTP 接口
│ ├── dto # 请求 DTO 与参数校验
│ ├── entity # 7 张表的 MyBatis-Plus 实体
│ ├── exception # 业务异常
│ ├── handler # 全局异常处理
│ ├── mapper # MyBatis-Plus Mapper
│ ├── service # Service 接口
│ ├── service/impl # 缓存、订单、秒杀实现
│ ├── task # 可选定时缓存预热
│ ├── util # 订单号生成器
│ └── vo # 返回 VO
├── src/main/resources
│ ├── application.yml
│ ├── application-dev.yml
│ ├── lua/idempotency.lua
│ ├── mapper/ProductStockMapper.xml
│ └── sql/init.sql
└── src/test # 缓存、幂等、秒杀和上下文测试
前置要求:Docker Desktop 已启动,3306 和 6379 端口未被占用。
docker compose up -d
docker compose psMySQL 容器首次创建数据卷时会自动执行 src/main/resources/sql/init.sql,创建 7 张表和演示商品、库存、秒杀活动。MySQL 与 Redis 分别使用 mysql_data、redis_data 命名卷持久化。
默认连接信息:
| 服务 | Host | Port | Database/User/Password |
|---|---|---|---|
| MySQL | localhost | 3306 | redis_practice / root / 123456 |
| Redis | localhost | 6379 | database 0 / 无密码 |
查看日志或停止容器:
docker compose logs -f mysql redis
docker compose down只有需要彻底重置练习数据时才执行 docker compose down -v;该命令会删除两个数据卷。
mvn clean test
mvn spring-boot:run服务默认监听 http://localhost:8080。开发配置均可由环境变量覆盖:
MYSQL_HOST=localhost MYSQL_PORT=3306 MYSQL_DATABASE=redis_practice \
MYSQL_USERNAME=root MYSQL_PASSWORD=123456 \
REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= \
mvn spring-boot:runWindows PowerShell 可先使用 $env:MYSQL_HOST="localhost" 的形式设置环境变量,再运行 Maven。
可选定时预热默认关闭。设置 practice.cache-warmup-enabled=true 后,默认每天 04:00 预热;也可通过 practice.cache-warmup-cron 修改 cron。
# String:验证码保存 5 分钟
curl -X POST http://localhost:8080/api/redis/string \
-H "Content-Type: application/json" \
-d '{"phone":"13800138000","code":"9527"}'
curl http://localhost:8080/api/redis/string/13800138000
# Hash:用户信息
curl -X POST http://localhost:8080/api/redis/hash/user/1001 \
-H "Content-Type: application/json" \
-d '{"name":"Wang","email":"wang@example.com","age":28}'
curl http://localhost:8080/api/redis/hash/user/1001
# List:右侧入队、左侧出队
curl -X POST http://localhost:8080/api/redis/list/message \
-H "Content-Type: application/json" -d '{"message":"hello redis"}'
curl http://localhost:8080/api/redis/list/message
# Set:收藏及人数
curl -X POST http://localhost:8080/api/redis/set/product/1/favorite/1001
curl http://localhost:8080/api/redis/set/product/1/favorite/count
# ZSet:积分与 Top 10
curl -X POST http://localhost:8080/api/redis/zset/ranking \
-H "Content-Type: application/json" -d '{"userId":1001,"score":50}'
curl http://localhost:8080/api/redis/zset/ranking# 第一次 cacheHit=false,第二次 cacheHit=true
curl http://localhost:8080/api/products/1
curl http://localhost:8080/api/products/1
# 互斥锁与逻辑过期版本
curl http://localhost:8080/api/products/1/mutex
curl http://localhost:8080/api/products/1/logical-expire
# 前 100 个有效商品预热
curl -X POST http://localhost:8080/api/products/cache/warm-up
# 更新商品:数据库提交成功后删除缓存
curl -X PUT http://localhost:8080/api/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"Redis 实战课程(新版)","description":"高并发缓存实战","price":109.00,"status":1}'
# 手工删除普通缓存与逻辑过期缓存
curl -X DELETE http://localhost:8080/api/products/1/cache先获取 Token,再将响应中的 token 放入请求头。相同 Token 第二次提交会返回重复请求。
curl -X POST http://localhost:8080/api/idempotency/token
curl -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-H "X-Request-Token: 替换为上一步token" \
-d '{"userId":1001,"items":[{"productId":1,"quantity":2}]}'初始化脚本中的活动 1 默认有效、商品库存 100。
curl -X POST http://localhost:8080/api/seckill/1 \
-H "Content-Type: application/json" \
-d '{"userId":2001,"quantity":1}'所有 Key 都由 RedisKeyConstants 生成,Controller 和业务代码不散落无规则字符串。
| Key | 类型 | TTL/用途 |
|---|---|---|
practice:captcha:{phone} |
String | 5 分钟验证码 |
practice:user:{userId} |
Hash | 用户属性 |
practice:message:queue |
List | FIFO 消息队列 |
practice:product:favorite:{productId} |
Set | 商品收藏用户 |
practice:user:ranking |
ZSet | 用户积分榜 |
practice:product:{productId} |
String(JSON) | 商品缓存,30~35 分钟 |
practice:product:logical:{productId} |
String(JSON) | 数据与逻辑过期时间 |
practice:lock:product:{productId} |
Redisson Lock | 商品缓存重建锁 |
practice:idempotency:token:{token} |
String | 10 分钟一次性 Token |
practice:lock:seckill:{activityId}:{userId} |
Redisson Lock | 用户级秒杀锁 |
practice:seckill:user:{activityId}:{userId} |
String | 已秒杀标记 |
缓存穿透是对数据库中不存在的数据持续发起查询:Redis 永远未命中,请求每次都会落到数据库。项目在数据库确认商品不存在后写入字符串 NULL,TTL 为 2 分钟;后续请求命中该哨兵后直接返回商品不存在,且不会向 Redis 写 Java null。
空值缓存的问题是短暂的数据不一致:在 2 分钟内新创建同 ID 商品仍可能被旧空值挡住;大量随机不存在 ID 也会占用内存。写入商品时应主动删除对应空值。更大规模场景可以在缓存之前增加布隆过滤器:它空间效率高且可快速拒绝一定不存在的 ID,但存在可控误判率,并需要处理元素初始化、更新和删除问题。
热点 Key 到期时,大量线程可能同时查询数据库,这就是缓存击穿。
- 互斥锁版本:读取缓存失败后竞争
RLock,成功者双重检查并重建;失败者短暂休眠后重试。锁设置等待时间和租约时间,finally中仅由持锁线程释放。 - 逻辑过期版本:缓存保存
data + expireTime。未过期直接返回;已过期仍返回旧值,并向线程池提交重建任务。工作线程获取锁后再次检查过期时间,仅一个线程查询数据库。该方式以短暂旧数据换低延迟和高可用。
商品缓存 TTL 为 30 分钟加 0~5 分钟随机值,预热时每个商品独立取随机值,避免同批 Key 集中失效。缓存读写异常时商品查询降级到 MySQL,并记录 productId 和异常日志。
生产环境还可叠加:网关或服务端限流保护入口;熔断器在数据库或 Redis 持续异常时快速失败;本地 Caffeine + Redis 形成多级缓存;Redis Cluster/哨兵提高可用性;预热任务分批执行并设置抖动;数据库连接池和只读副本作为最后容量边界。
Redisson 的 RLock 以 Redis 原子命令和 Lua 保证加解锁安全,锁值关联客户端/线程身份,防止误删其他线程的锁。项目所有锁都:设置有限等待和租约、在 finally 中释放、释放前调用 isHeldByCurrentThread()。
商品锁粒度为 productId;秒杀锁粒度为 activityId + userId,因此同一用户的重复请求串行化,不同用户仍能并行。Redis 锁不是库存安全的唯一依据,数据库条件更新始终保留。
POST /api/idempotency/token 创建 UUID 并保存 10 分钟。下单时 lua/idempotency.lua 在 Redis 内原子执行 EXISTS + DEL:首次返回 1,后续返回 0,避免客户端侧先 GET 再 DELETE 的竞态。t_order.request_no 与 t_idempotency_record.request_no 都有唯一索引,即使 Redis 边界发生异常,数据库仍会拒绝重复记录。
一次性 Token 在数据库事务失败后已经被消费,这是本练习方案刻意暴露的取舍。生产环境可将请求状态机持久化,支持处理中、成功、失败可重试以及相同响应重放。
读使用 Cache Aside;更新不直接覆盖缓存。updateProduct 在 Spring 数据库事务内更新 MySQL,通过 TransactionSynchronizationManager 注册 afterCommit 回调,只有提交成功后才删除普通缓存和逻辑缓存。这样避免提交前删除缓存后事务回滚造成的数据不一致。删除失败会记录错误,生产环境可用消息表/事务消息/延迟双删进行最终补偿。
秒杀流程为:校验活动 → 获取用户级锁 → 检查用户标记 → 进入独立事务 Service → 数据库条件扣库存 → 创建订单和明细 → 写扣减日志 → 提交事务 → 写 Redis 用户标记 → 释放锁。
核心 SQL 只有在 available_stock >= quantity 时更新成功,通过受影响行数判断库存是否充足:
UPDATE t_product_stock
SET available_stock = available_stock - ?,
version = version + 1,
update_time = NOW()
WHERE product_id = ?
AND available_stock >= ?
AND deleted = 0;同一活动、同一用户还有数据库唯一索引 uk_activity_user。即使 Redis 标记写入失败或分布式锁发生边界问题,条件更新保证库存不会小于 0,唯一索引保证用户不会生成第二个秒杀订单。
全部测试无需启动 MySQL/Redis,使用可控 Mock 模拟依赖边界并验证核心并发不变量:
mvn clean test
# 单独运行热点缓存并发测试
mvn -Dtest=ProductLogicalExpireConcurrencyTest test
# 单独运行 100 线程秒杀测试
mvn -Dtest=SeckillConcurrencyTest test
# 单独运行幂等并发测试
mvn -Dtest=OrderIdempotencyConcurrencyTest test当前测试覆盖 8 个用例:Spring 上下文、首次 MySQL/再次 Redis、空值缓存、Redis 降级、逻辑过期单次重建、同 Token 单订单、100 线程不超卖、同用户不重复秒杀。
连接真实容器做压测时,可用 JMeter、wrk 或 Apache Bench 为每个虚拟用户分配不同 userId,结束后检查:
SELECT available_stock FROM t_product_stock WHERE product_id = 1;
SELECT COUNT(*) FROM t_order WHERE activity_id = 1 AND deleted = 0;
SELECT activity_id, user_id, COUNT(*) AS cnt
FROM t_order WHERE activity_id = 1 AND deleted = 0
GROUP BY activity_id, user_id HAVING cnt > 1;| 练习 | 验证方法 | 预期结果 |
|---|---|---|
| String | 写验证码后 TTL practice:captcha:13800138000 |
TTL 接近 300 秒 |
| Hash | HGETALL practice:user:1001 |
返回 name/email/age |
| List | 连续入队再出队 | 先进先出 |
| Set | 同一用户重复收藏后查询人数 | 人数只增加一次 |
| ZSet | 多用户增加积分后查询 Top 10 | 按积分倒序 |
| Cache Aside | 连续查询商品两次 | cacheHit 为 false、true |
| 穿透 | 连续查询不存在 ID,观察日志和 Redis | 只查一次 DB,缓存值为 NULL |
| 雪崩 | 预热后批量执行 TTL practice:product:* |
TTL 分散在 1800~2100 秒 |
| 互斥击穿 | 删除商品 Key 后并发访问 /mutex |
仅一个线程重建 |
| 逻辑过期 | 构造过期缓存后并发访问 | 立即返回旧值,仅一次异步 DB 查询 |
| 缓存一致性 | 更新商品后检查 Redis | 事务提交后 Key 被删除 |
| 幂等 | 同 Token 连续创建两次订单 | 首次成功,第二次重复请求 |
| 秒杀锁 | 同一 userId 连续请求两次 | 只有一次成功 |
| 防超卖 | 运行 100 线程测试或真实压测 | 库存始终大于等于 0 |
| Redis 降级 | 暂停 Redis 后查询已存在商品 | 从 MySQL 返回且记录降级日志 |
Redis CLI 辅助命令:
docker exec -it redis-practice-redis redis-cli
GET practice:product:1
TTL practice:product:1
KEYS practice:*开发环境可以用 KEYS 观察练习结果;生产环境应使用渐进式 SCAN,避免阻塞 Redis 单线程事件循环。