Redis 完结篇——Redis 热点问题在 Spring Boot 项目中实战

0 阅读7分钟

本次导航

  • 我专门做了个 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。不是再堆一页命令清单,是一个能打开浏览器、左边切场景、对错两套路径并排跑的工程。代码在:

github.com/iweidujiang…

欢迎大家 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:

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 里:

GET product:1001
TTL product:1001

TTL 会落在 300 到 360 秒之间。写入时加了一点随机数,避免同一批商品同一秒过期。

改价走另一条:先改 ProductStore,再 DEL product:{id}。不要先改缓存再改库——库失败了,Redis 里就是假价。

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;
}
curl -X PUT "http://localhost:8080/api/products/1001/price?priceCents=3900"

cli 里 GET product:1001 应该已经没了,再 GET 接口,会回源库、把 3900 写回去。删缓存后到下一次回填之间,并发读可能把旧值又 SET 回去,电商详情一般能接受这几毫秒;库存、余额别用这套。

三、穿透:没有的东西别去烦数据库

扫 product:1 到 product:9999999,库里本来就没有。项目里两道闸:

  1. 启动时把真实 ID 灌进布隆过滤器 bf:product_id(Redis 8 官方镜像自带 Bloom)。BF.EXISTS 说没有,接口直接返回,不碰库。
  2. 布隆说可能有、库查空了,缓存 __nil__,30 秒。

打开 缓存穿透,查 99999,左侧布隆状态、查询结果和 cache-meta 会写在同一块日志里。

cli:

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 把判断和扣减捆在一次执行里:

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:

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 次手机壳。

命令行对照:

MEMORY USAGE mall:big:hash
UNLINK mall:big:hash

不要 DEL。元素一多,主线程同步释放内存会卡。UNLINK 立刻返回,后台慢慢收。

热 Key 打完后,Redis 8.6:

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 该项目:

github.com/iweidujiang…


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