加群联系作者vx:xiaoda0423
仓库地址:https://webvueblog.github.io/JavaPlusDoc/
https://github.com/webVueBlog/fastapi_plus
https://webvueblog.github.io/JavaPlusDoc/
点击勘误issues,哪吒感谢大家的阅读
如果目标是"在 Redis 上把读流量兜住 、即使 Redis 未命中也不把洪峰直接打到 Mongo ",可以把"防穿透/合并/限流/热键打散/负缓存/失效广播"全放到 Redis 层 完结。下面给出可落地的 Redis 侧方案(键模型 + Lua + Pub/Sub/Stream + 用法示例)。
1) 键模型(用 hash-tag 保证相关键同槽)
这样在 Redis Cluster 里多键原子操作也能跑(Lua 限同槽)。
-
当前订单映射(用户→进行中订单ID或空)
cur:{op}:{uid} -> orderId | "-"
(TTL 120--300s,±10% 抖动) -
订单详情
od:{orderId} -> JSON
(TTL 300--600s,±10%) -
订单列表索引(分页)
olist:{op}:{uid} -> ZSET(ts -> orderId)
(可选) -
热点复制(打散热 key)
cur:{op}:{uid}#1..N
(读随机 1..N,写 N 份) -
互斥闸(只允许一个 回源)
gate:{op}:{uid}
(短 TTL 2--5s) -
失效广播(清 L1 或提醒客户端)
PUBLISH inv:{op}:{uid} <payload>
-
回源任务(可异步处理)
XADD miss:cur * key cur:{op}:{uid}
{}
里的内容做 hash-tag,例如cur:{op123:uid456}
,确保同槽。
2) 纯 Redis 原子逻辑(Lua)
2.1 读取当前订单(带"单飞闸 + 负缓存")
效果:
-
命中直接返回
-
未命中时抢闸 (只有 1 个客户端拿到),其他立即收到
_MISS_LOCKED_
,不去 Mongo -
支持热点复制读取
go
-- KEYS[1] = cur key (或某个副本key)
-- KEYS[2] = gate key
-- ARGV[1] = gate ttl ms
local v = redis.call('GET', KEYS[1])
if v then
return v -- 命中:orderId 或 "-"
end
-- 未命中:尝试抢闸
local ok = redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1])
if ok then
return '_MISS_NEED_FILL_' -- 只有拿到闸的人去"回源/异步填充"
else
return '_MISS_LOCKED_' -- 别的请求立即返回,不打库
end
Java 调用示例(选随机副本读取):
go
String baseKey = "cur:{%s:%s}".formatted(op, uid);
String curKey = baseKey + "#" + (ThreadLocalRandom.current().nextInt(3) + 1);
String gateKey = "gate:{%s:%s}".formatted(op, uid);
String res = stringRedisTemplate.execute(scriptGetWithGate, List.of(curKey, gateKey), "3000");
switch (res) {
case "-": return null; // 负缓存:无当前订单
case "_MISS_NEED_FILL_":
// 你可以:1) 轻量返回旧值/提示稍后 2) XADD miss:cur 交给异步去填充 3) 少量同步回源
// 这里推荐:写入 Stream,再立即返回(保护 Mongo)
stringRedisTemplate.opsForStream().add("miss:cur", Map.of("key", baseKey));
return null;
case "_MISS_LOCKED_":
// 不打库,快速返回(或短暂自旋再查一次)
return null;
default:
return "NULL".equals(res) || "-".equals(res) ? null : fetchDetail(res);
}
要极致一致 也可以让"拿到闸"的那一个同步回源;但高峰期建议走 Stream 异步(见 §3)。
2.2 一次性写入"当前订单 + 详情 + N 份热 key 副本"(原子)
效果:订单创建/状态变化时,只打一把脚本,完成 MSET + EXPIRE + 广播。
go
-- KEYS: cur#1..N, detailKey, invChannel, baseCurKey
-- ARGV: orderId, ttlCurSec, ttlDetSec
local orderId = ARGV[1]
local ttlCur = tonumber(ARGV[2])
local ttlDet = tonumber(ARGV[3])
-- 写 N 份 cur 副本
for i=1,#KEYS-3 do
redis.call('SET', KEYS[i], orderId, 'EX', ttlCur + math.random(0,ttlCur/10))
end
-- 写详情(这里假设上层已先把 JSON 放入临时键,或直接传 JSON 用 EVALSHA 限长注意)
redis.call('SET', KEYS[#KEYS-2], redis.call('GET', KEYS[#KEYS-2]) or '', 'EX', ttlDet + math.random(0,ttlDet/10))
-- 广播无论如何发一下,提醒 L1 失效/刷新
redis.call('PUBLISH', KEYS[#KEYS-1], KEYS[#KEYS])
return 'OK'
也可以把负缓存 落盘:把
orderId
改成"-"
,TTL 短一点(30--60s)。
2.3 令牌桶(全在 Redis,限制落库速率)
效果:只有拿到令牌的人才允许"回源 Mongo",否则返回降级。
go
-- KEYS[1] = bucket key
-- ARGV[1] = capacity
-- ARGV[2] = refill tokens per second
-- ARGV[3] = now (ms)
-- ARGV[4] = tokens to take
local cap = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
local lastTokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or cap)
local lastRefill = tonumber(redis.call('HGET', KEYS[1], 'ts') or now)
local delta = math.max(0, now - lastRefill) / 1000.0 * rate
local current = math.min(cap, lastTokens + delta)
if current < need then
redis.call('HSET', KEYS[1], 'tokens', current, 'ts', now)
redis.call('EXPIRE', KEYS[1], 60)
return 0
else
redis.call('HSET', KEYS[1], 'tokens', current - need, 'ts', now)
redis.call('EXPIRE', KEYS[1], 60)
return 1
end
Java 侧:
go
boolean canHitDB = Boolean.TRUE.equals(
stringRedisTemplate.execute(tokenBucketScript,
List.of("rl:dbFallback"),
"2000", "500", String.valueOf(System.currentTimeMillis()), "1"));
if (!canHitDB) return null; // 快速降级,不落库
3) 用 Redis 异步回源(不"打 Mongo 洪峰")
3.1 生产"回源任务"
未命中且拿到闸的请求:
go
XADD miss:cur * key "cur:{op:uid}"
3.2 消费"回源任务"(多 worker 竞消费)
go
XGROUP CREATE miss:cur g1 $ MKSTREAM # 初始化一次
XREADGROUP GROUP g1 c1 COUNT 100 BLOCK 2000 STREAMS miss:cur >
# worker 处理:查 Mongo → set cur/od → PUBLISH inv:... → XACK
这样回源的并发 由
XREADGROUP
的worker 数量 控制,不随前台流量放大 ;查库速率再叠加上面的令牌桶更稳。
4) 热点 Key 打散(全在 Redis)
-
读 :随机读
cur#1..N
(或本机一致性哈希选一份) -
写:Lua 一次写 N 份(见 §2.2)
-
删/失效 :同样脚本批量
DEL
/EXPIRE
+PUBLISH
N 取 3~5 即可,能大幅摊薄单 key QPS。
5) 列表页也"只在 Redis 处理"
-
用户"我的订单列表":
-
-
ZREVRANGE olist:{op}:{uid} offset count
拿一页 orderId -
一次
MGET
/Pipeline 拉取od:{orderId...}
-
缺失的用低频异步补全(Stream),前台先返回已有的
-
列表长期数据可只放"最近 N 条"在 Redis,老数据分页再走后端"离线索引 + 异步补全"。
6) 失效广播(Redis Pub/Sub)
-
写路径 变更后:
PUBLISH inv:{op}:{uid} <orderId|->
-
应用订阅
inv:*
,拿到消息就清 L1或触发局部刷新 -
这样避免 L2 改了、L1 还留旧值导致命中错误
7) 策略小抄(全靠 Redis 就能做到的)
-
负缓存 :用
"-"
/"NULL"
短 TTL,防止穿透 -
随机 TTL:±10--20% 抖动,避同刻雪崩
-
单飞互斥 :短期
SET NX PX
或 RedLock(倾向单实例短锁即可) -
令牌桶限流 :Lua(§2.3)限制允许回源的频率
-
热点复制 :
#1..N
-
两段式检查(双检) :拿闸后再 GET一次,避免重复回源
-
Pipeline/MGET/MSET:批量 IO 降 RTT
-
Key 空间规划:统一前缀 + hash-tag,便于 Cluster 同槽操作
-
淘汰策略 :
allkeys-lru
或volatile-lru
;给所有缓存都设 TTL
8) 最小落地清单
-
采用上面的 键模型;
-
接入 Lua 读闸脚本 + 令牌桶脚本;
-
读路径:L1→L2→闸 ;未命中XADD并快速返回;
-
后台 worker:XREADGROUP 拉取 miss → 回源 → 批量写回 Lua → PUBLISH;
-
写路径:订单状态变化走批量写回 Lua(同时发广播);
-
Redis Cluster:给相关键加 hash-tag ;热键做 #1..N;
-
监控:命中率、闸命中率、回源 QPS、令牌桶拒绝数、Stream 积压。
有了这套全 Redis 侧的"闸 + 合并 + 负缓存 + 打散 + 广播 + 异步回源" ,即使峰值 10 万 QPS 的查询,也能把"真正落库"的请求稳定压在一个可控水平(例如每秒几百),Mongo 不会被尖峰打穿。
如果要扛1万~10万 QPS 的"查当前换电订单/订单列表" ,但Redis 一旦未命中 就会把流量瞬间打爆 MongoDB ,做法要从"读模型 + 缓存治理 + 限流与合并 + Mongo 优化"四层一起上。下面给你一套可直接落地的方案与示例代码。
总体思路(先定规则)
-
读模型前置到 Redis:把"用户→当前订单ID""订单详情"维护在 Redis,绝大多数查询不落库。
-
两级缓存 + 防击穿 :L1 Caffeine(进程内 3060s)+ L2 Redis(510min,带随机抖动);负缓存 、单飞/互斥锁 、异步刷新。
-
水闸与合并 :严格限制每秒能落 Mongo 的请求数(全局/单键),同键请求合并一次查库。
-
Mongo 只做"回源" :只在冷启动/失效时查库;建覆盖索引 、精确投影 ,必要时分片 或读从。
Redis 键设计(建议)
-
当前订单映射:
ord:cur:{op}:{uid} -> {orderId|NULL}
(TTL 120~300s,随机±10% ) -
订单详情:
ord:det:{orderId} -> JSON
(TTL 300~600s,±10%) -
热点保护(可选):复制 N 份
ord:cur:{op}:{uid}#1..N
,读随机挑一份。 -
回源互斥锁:
lock:ord:cur:{op}:{uid}
(过期 5s)。 -
L1 本地缓存:同样两个 key,TTL 30~60s。
负缓存 :当确定"无当前订单"时,把
ord:cur:*
设为"NULL"
,TTL 30~60s,防穿透。
查询流程(防雪崩/防击穿/合并)
以"查用户当前订单"为例:
-
L1 命中 → 直接返回。
-
L2 命中(Redis)
-
值为
"NULL"
→ 直接返回"无"。 -
有
orderId
→ 继续查ord:det:{orderId}
,缺则批量 MGET/Pipeline 获取,仍缺再按互斥逻辑回源。
-
缓存都未命中 → 尝试获取互斥锁 (Redisson
tryLock
/ RedisSETNX
) -
-
拿到锁 :再检查一次 Redis(双检),仍然未命中 → 限流器 允许后回源 Mongo,写回 Redis(含负缓存),发布失效广播清 L1;释放锁。
-
没拿到锁 :短暂自旋/订阅通知 (最多 50~100ms),若仍没有则返回旧值/降级结果(避免把并发都压到 Mongo)。
可选:refresh-ahead(软TTL)------距离过期很近时由后台线程提前刷新;前台仍用旧值,避免尖峰时全部同时失效。
代码骨架(Spring Boot)
1) 服务方法(L1 + L2 + 单飞 + 负缓存)
go@Service public class OrderQueryService { private final Cache<String, String> l1 = Caffeine.newBuilder() .maximumSize(300_000).expireAfterWrite(Duration.ofSeconds(45)).build(); @Autowired StringRedisTemplate srt; @Autowired RedissonClient redisson; @Autowired OrderRepository orderRepo; // Mongo 回源 @Autowired RateLimiter mongoLimiter; // 每实例/全局限流(如令牌桶) private static final String NULL = "NULL"; public OrderDto getCurrentOrder(String op, String uid) { String curKey = "ord:cur:" + op + ":" + uid; // L1 String ordId = l1.getIfPresent(curKey); if (ordId != null) return fetchDetailOrNull(ordId); // L2 ordId = srt.opsForValue().get(curKey); if (NULL.equals(ordId)) { l1.put(curKey, NULL); return null; } if (ordId != null) { l1.put(curKey, ordId); return fetchDetailOrNull(ordId); } // 互斥 + 双检 RLock lock = redisson.getLock("lock:" + curKey); boolean locked = false; try { locked = lock.tryLock(0, 5, TimeUnit.SECONDS); if (locked) { // 双检 ordId = srt.opsForValue().get(curKey); if (ordId != null) { l1.put(curKey, ordId); return fetchDetailOrNull(ordId); } // 限流:保护 Mongo if (!mongoLimiter.tryAcquire()) { // 返回降级:可返回空/提示"稍后重试"/旧快照 return null; } // 回源 Mongo(必须有覆盖索引) OrderDoc doc = orderRepo.findCurrentByUser(op, uid); // 精确查 if (doc == null) { setWithJitter(curKey, NULL, 30, 60); l1.put(curKey, NULL); return null; } ordId = doc.getOrderId(); // 写回 Redis(当前id + 详情) setWithJitter(curKey, ordId, 120, 300); srt.opsForValue().set("ord:det:" + ordId, toJson(doc), getJitter(300, 600)); // 通知各实例清 L1(可用 Redis Pub/Sub) srt.convertAndSend("cache:invalidate", curKey); l1.put(curKey, ordId); return map(doc); } else { // 未拿到锁:短暂等他人填充 long end = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(80); while (System.nanoTime() < end) { String v = srt.opsForValue().get(curKey); if (v != null) return NULL.equals(v) ? null : fetchDetailOrNull(v); Thread.onSpinWait(); } // 仍无:返回降级结果 return null; } } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return null; } finally { if (locked) lock.unlock(); } } private OrderDto fetchDetailOrNull(String ordId) { if (NULL.equals(ordId)) return null; String detKey = "ord:det:" + ordId; String json = l1.getIfPresent(detKey); if (json != null) return fromJson(json); json = srt.opsForValue().get(detKey); if (json != null) { l1.put(detKey, json); return fromJson(json); } // 详情缺失:轻回源或延迟刷新(不要高频落库) return null; // 或放宽:小概率回源 + 写回 } private void setWithJitter(String key, String val, int minSec, int maxSec) { srt.opsForValue().set(key, val, getJitter(minSec, maxSec)); } private Duration getJitter(int min, int max) { int ttl = ThreadLocalRandom.current().nextInt(min, max + 1); return Duration.ofSeconds(ttl); } }
上面包含:L1/L2、负缓存、互斥锁、双检、Mongo 限流、短暂自旋合并 。
生产可再加:本地 singleflight(
ConcurrentHashMap<String,CompletableFuture<?>>
) ,进一步合并同键请求。2) 写路径(确保缓存一致)
-
Cache-Aside(推荐) :订单状态变更/创建 → 先写 Mongo 成功 → 删除/更新 Redis
-
若是"当前订单"字段:变更时同时 更新
ord:cur:*
与ord:det:*
(或直接删,等读端再填)。 -
广播 L1 失效 :
convertAndSend("cache:invalidate", key)
;各实例订阅后l1.invalidate(key)
。
保护 Mongo 的"四道闸"
-
令牌桶/信号量 :单实例/全局控制落库 QPS(例如 1000/s);超出直接降级。
-
每键互斥 :同用户同一时刻只有一个回源。
-
批量/聚合 (列表页):一次取多条订单 ID → MGET 详情,缺的集中补。
-
负缓存 + 软TTL刷新:无数据也缓存,提前刷新避免同刻过期雪崩。
MongoDB 优化(必须做)
-
覆盖索引
-
-
当前订单:
{ operatorId:1, userId:1, status:1 }
(partial :status in ['CREATED','PAYING','RUNNING']
) -
列表分页:
{ operatorId:1, userId:1, createdAt:-1 }
(再投影需要的字段)
-
-
精确投影:只取页面需要的字段,减对象体积。
-
连接池 :
maxPoolSize
合理(100~500/实例),waitQueueTimeoutMS
限制排队。 -
读从(可选) :允许略旧可用
secondaryPreferred
(当前订单通常不建议)。 -
分片 :量级特别大按
{operatorId, userId}
或{operatorId, createdAt}
分片,避热点。
额外增强(按需)
-
Bloom Filter / Bitmap:维护"用户是否可能有进行中订单"的布隆过滤器,过滤无效落库。
-
热点复制 :
ord:cur:*#1..N
写 N 份,读随机一份;写时用 Lua 一次性更新全部副本。 -
预热 :登录成功、创建/变更订单时顺手 把
ord:cur
/ord:det
写进缓存。 -
DLQ/监控:指标上报命中率、落库QPS、锁等待、批量大小、Mongo p95/p99。
一句话总结
-
读模型进 Redis + L1/L2 两级缓存 → 把 99% 查询挡在缓存;
-
互斥/合并 + 负缓存 + 随机TTL + 限流 → 防止未命中瞬间把 Mongo 打爆;
-
Mongo 侧覆盖索引 + 精确投影 → 回源也快;
-
这套下来,10万 QPS 的读流量可稳住在几百到几千 QPS以内落库,系统不抖。
-