本次导航
- 我专门做了个 Redis Mall 小 demo
- 商品详情:Cache-Aside,改价先改库再删缓存
- 穿透、击穿、雪崩在代码里长什么样
- 下单:Lua 扣库存,别 GET 完再 SET
- 限流、大 Key、热 Key,用 redis-cli 对着看
JDK 21,Spring Boot 3.5.9。数据库故意做成内存里的 ProductStore,每次查询睡 30ms 并计数,方便看"打到 DB 多少次"。
一、专门写了一个能点的项目
前面 Redis 系列文章,为了方便和使用 Redis 命令,我用的是 redis-cli 进行实操,SET、GET、Lua、Bloom、HOTKEYS,一条条敲过去,先练练手感。
这篇文章主要想从实际场景出发:缓存和库谁先写、热点失效谁去回源、库存能不能 GET 完再 SET、限流脚本能不能拆成两次网络。
所以我按商品中心的样子,专门写了 Redis Mall。不是再堆一页命令清单,是一个能打开浏览器、左边切场景、对错两套路径并排跑的工程。代码在:
欢迎大家 fork、star。
启动后打开 **http://localhost:8080**。
左侧七个菜单:商品与缓存、穿透、击穿、雪崩、超卖、限流、大 Key / 热 Key。正式商品和下单接口有分钟限流,场景按钮不限,方便把问题打出来。

预置了三个商品:1001 手机壳、1002 充电头、1003 数据线。库存写在 stock:{id}。
| 接口 | 干什么 |
|---|---|
GET /api/products/{id} |
详情,带缓存 |
PUT /api/products/{id}/price?priceCents=3900 |
改价 |
POST /api/orders |
下单扣库存 |
/api/scenarios/* |
页面里各场景按钮打的对比接口 |
二、详情页:先 Redis,没有再库
打开 商品与缓存,点刷新。三张卡片上能看到售价、库存和缓存 TTL。改个价格后再刷新,TTL 会重新出现。

读路径在 ProductCacheService.get:
java
public Optional<Product> get(long id) {
String cacheKey = RedisKeys.product(id);
String cached = redis.opsForValue().get(cacheKey);
if (NIL.equals(cached)) {
return Optional.empty();
}
if (cached != null) {
return Optional.of(readJson(cached));
}
if (!mightExist(id)) {
return Optional.empty();
}
return loadWithMutex(id, cacheKey);
}
__nil__ 表示查过、库里没有。真没有和"还没缓存"要分开,不然穿透会把数据库空跑穿。

第二次 dbHits 不该再涨。redis-cli 里:
bash
GET product:1001
TTL product:1001
TTL 会落在 300 到 360 秒之间。写入时加了一点随机数,避免同一批商品同一秒过期。
改价走另一条:先改 ProductStore,再 DEL product:{id}。不要先改缓存再改库------库失败了,Redis 里就是假价。
java
public Product updatePrice(long id, int priceCents) {
Product current = store.findById(id)
.orElseThrow(() -> new IllegalArgumentException("商品不存在: " + id));
Product updated = current.withPrice(priceCents);
store.save(updated);
redis.delete(RedisKeys.product(id));
return updated;
}
bash
curl -X PUT "http://localhost:8080/api/products/1001/price?priceCents=3900"
cli 里 GET product:1001 应该已经没了,再 GET 接口,会回源库、把 3900 写回去。删缓存后到下一次回填之间,并发读可能把旧值又 SET 回去,电商详情一般能接受这几毫秒;库存、余额别用这套。
三、穿透:没有的东西别去烦数据库
扫 product:1 到 product:9999999,库里本来就没有。项目里两道闸:
- 启动时把真实 ID 灌进布隆过滤器
bf:product_id(Redis 8 官方镜像自带 Bloom)。BF.EXISTS说没有,接口直接返回,不碰库。 - 布隆说可能有、库查空了,缓存
__nil__,30 秒。
打开 缓存穿透,查 99999,左侧布隆状态、查询结果和 cache-meta 会写在同一块日志里。

cli:
bash
BF.EXISTS bf:product_id 99999
GET product:99999
TTL product:99999
过滤器返回 0 的话,连 __nil__ 都不会写,这是更干净的情况。模块没有时,代码会退回只靠空值缓存,日志里有一句 Bloom 不可用。
四、击穿:热点失效,别让所有人回源
ProductStore.findById 每次 +1 并 sleep 30ms。打开 缓存击穿:左侧无锁、右侧有锁,各跑一遍。
无锁时 dbHits 会接近 20;有锁应掉到 1 附近。数字当场出来,比口头讲"加一把互斥锁"管用。对应接口是 POST /api/scenarios/stampede。


解锁用 lua/unlock.lua:对得上自己的 token 才 DEL,避免锁过期后误删别人的。
SET NX EX 3 只是回源互斥,不是跨机房的 Redlock。单实例缓存够用。
五、雪崩:别让一批 Key 同一秒过期
打开 缓存雪崩 。左侧 20 个 Key 都写成 30 秒,distinctTtl 会是 1;右侧走正式回填那套 300 到 360 秒随机,剩余时间会散开。Key 名叫 mall:av:1 到 mall:av:20。

这和商品详情写入时加抖动是同一件事。详情页改价后再刷新,TTL 也不会钉死在 300。
六、下单:库存不能先读后写
打开 库存超卖 ,先跑 GET 再 SET。库存 10、20 个线程各买 1 件,sold 往往会大于 10。
再跑 Lua,sold 应是 10,剩余 "0"。下面还可以走正式下单:商品锁 + Lua。接口:POST /api/scenarios/stock/race。

Lua 把判断和扣减捆在一次执行里:
1:7:D:\a-github-project\redis-mall\src\main\resources\lua\decr_stock.lua
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local buy = tonumber(ARGV[1])
if stock < buy then
return -1
end
redis.call('DECRBY', KEYS[1], buy)
return stock - buy
正式下单还在 Lua 外面加了一把 lock:order:{id}:同一商品的下单入口串行一下,后面如果要写订单表、发消息,不会和扣库存缠在一起。
解锁同样走那段校验 Lua。

七、限流:别把 Redis 当免费的无限入口
/api/products/** 和 /api/orders/** 走 RateLimitInterceptor。
脚本是 INCR,第一次带上 60 秒过期,超过每分钟 60 次返回 429。
页面 接口限流 会连发 70 次详情,超过每分钟 60 次会出现 429。

cli:
bash
GET rate:ip:127.0.0.1
TTL rate:ip:127.0.0.1
INCR 和 EXPIRE 必须在同一段脚本里。
拆成两次请求,进程挂在中间,这个计数 Key 可能永不过期,限流等于失效。
八、大 Key 和热 Key
打开 大 Key / 热 Key,点生成和打热即可。左边写出 2 万个 field 的 Hash,右边连读 500 次手机壳。

命令行对照:
bash
MEMORY USAGE mall:big:hash
UNLINK mall:big:hash
不要 DEL。元素一多,主线程同步释放内存会卡。UNLINK 立刻返回,后台慢慢收。
热 Key 打完后,Redis 8.6:
bash
HOTKEYS START METRICS 2 CPU NET
HOTKEYS GET
HOTKEYS STOP
列表里大概率能看到 product:1001。
线上热点不要只靠互斥回源,详情还可以在应用里加 Caffeine,或者把同一个商品拆成几个副本 Key 随机读。
慢查询也是老问题。应用里已经用了 Lettuce 连接池(max-active: 16)。
不要在代码或运维脚本里 KEYS *,用 SCAN,SLOWLOG GET 能看见谁拖慢了主线程。
连接打满通常不是"再加大 max-active"就能完:先看有没有大 Key、慢 Lua、没有超时的命令把连接占住。
timeout: 3s 和 max-wait: 2s 就是给这种时候用的。
九、项目里建议默认带上的几条
- 读:Redis → 空标记 / 布隆 → 互斥回源 DB → 回填,TTL 加随机
- 写:先 DB,再删缓存
- 库存、余额:Lua,禁止 GET 完再 SET
- 锁:
SET NX EX+ 按 token 删除;回源互斥和业务锁都这样 - 限流:INCR 和 EXPIRE 写在同一段脚本
- 删除大结构:
UNLINK - 连接池要有上限和等待超时;慢查询定期看
这些不是新命令,是进 Spring 项目之后最容易省掉的那几步。
Redis Mall 把它们收进同一套页面里,方便以后对照着改自己的业务。
十、系列到这里
最后落到 Redis Mall 这个商品中心。前面把命令练熟,这篇把同一套问题放进 Spring 项目里跑一遍。
Redis 能当缓存、队列、锁、限流器、甚至向量检索,但每一个都要记得:它是单线程执行的内存服务,大 Key、热 Key、慢脚本、错误的过期策略,都会把整站拖进同一条阻塞队列。
感兴趣的同学可以 fork、star 该项目:

本次导航结束。Redis 系列完结,欢迎 关注、点赞、转发。