加群联系作者vx:xiaoda0423
仓库地址:webvueblog.github.io/JavaPlusDoc...
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 - 支持热点复制读取
lua
-- 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 调用示例(选随机副本读取):
dart
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 + 广播。
lua
-- 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",否则返回降级。
lua
-- 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 侧:
typescript
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 生产"回源任务"
未命中且拿到闸的请求:
vbnet
XADD miss:cur * key "cur:{op:uid}"
3.2 消费"回源任务"(多 worker 竞消费)
ruby
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(进程内 30
60s)+ 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 + 单飞 + 负缓存)
ini
@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以内落库,系统不抖。