促销系统崩溃后,我们加了限流和熔断。系统不崩了,但数据库压力还是很大。
高峰期每秒 1 万个请求,限流后降到 5000,但 70% 都是重复查询同一批热门商品。请求一个接一个打到数据库,PostgreSQL,查询响应时间 P99 在 500ms。
测试反馈:“用户刷新太慢了,体验不好。”
我们考虑过读写分离(查询走从库),但改动太大:所有查询逻辑要改,还要处理主从延迟。投入产出比不高。
更简单的方案是加缓存。于是我们搞了三级缓存。
三级缓存架构
L1(内存缓存):单机 Node.js 进程内存,LRU 淘汰,5 秒 TTL
L2(分布式缓存):Redis 内存数据库,5 分钟 TTL
L3(数据库):PostgreSQL,真实数据源
graph TB Request((请求进来)) Request --> L1[L1: 内存缓存<br/>5s TTL, LRU] L1 -->|命中| Return1[直接返回] L1 -->|未命中| L2[L2: Redis<br/>5min TTL] L2 -->|命中| WriteL1[回写 L1] WriteL1 --> Return2[返回数据] L2 -->|未命中| Bloom[布隆过滤器<br/>未实现] Bloom -.->|不存在| ReturnNull[返回 null] Bloom -.->|可能存在| DB[L3: 数据库] DB -->|查到| WriteCache[写入 L2 + L1] WriteCache --> Return3[返回数据] DB -->|不存在| Return4[返回 null] Note[注:布隆过滤器保留在架构图中<br/>作为完整的技术思路参考<br/>实际未实现] style Request fill:#dbeafe,stroke:#3b82f6,stroke-width:2px style L1 fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px style L2 fill:#ffe8cc,stroke:#d9480f,stroke-width:2px style Bloom fill:#f8f9fa,stroke:#868e96,stroke-width:2px,stroke-dasharray: 5 5 style DB fill:#f3d9fa,stroke:#862e9c,stroke-width:2px style Note fill:#fff4e6,stroke:#e67700,stroke-width:1px
注:图中布隆过滤器实际未实现,保留是为了展示完整的技术方案思路。
实现代码
const express = require('express')
const NodeCache = require('node-cache')
const Redis = require('ioredis')
const { Pool } = require('pg')
const app = express()
const L1 = new NodeCache({ stdTTL: 5, maxKeys: 10000 })
const L2 = new Redis({ host: 'redis-cluster' })
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL })
app.get('/api/product/:id', async (req, res) => {
const product = await getProduct(req.params.id)
if (!product) return res.status(404).json({ error: 'Product not found' })
res.json(product)
})
async function getProduct(id) {
// 查 L1
const l1Data = L1.get(id)
if (l1Data) return l1Data
// 查 L2
const l2Data = await L2.get(`product:${id}`)
if (l2Data) {
const parsed = JSON.parse(l2Data)
L1.set(id, parsed)
return parsed
}
// 查数据库
const result = await pgPool.query('SELECT * FROM products WHERE id = $1', [id])
if (result.rows.length === 0) return null
const dbData = result.rows[0]
// 回写缓存
await L2.setex(`product:${id}`, 300, JSON.stringify(dbData))
L1.set(id, dbData)
return dbData
}效果:P99 响应时间从 500ms 降到 50ms,数据库 QPS 从 5000 降到 200。
为什么需要两层缓存
最开始我们只用了 Redis(L2),没有本地内存缓存(L1)。响应时间从 500ms 降到 180ms,有改善但不够好。
Redis 访问的开销:
- 网络 I/O:Node.js 到 Redis 的网络往返
- Redis 查询:序列化/反序列化
- 总计:单次 Redis 查询
高并发下,每个请求都访问 Redis,网络 I/O 成了新瓶颈。
L1 内存缓存的价值
加入 L1 后,热点数据(90% 的请求查询同一批商品)直接从内存返回,不走网络。
const NodeCache = require('node-cache')
const L1 = new NodeCache({
stdTTL: 5, // 5 秒过期
maxKeys: 10000, // 最多缓存 1 万个 key
useClones: false // 不克隆对象,性能更好
})
// L1 查询:纯内存操作,< 0.1ms
const data = L1.get('product:123')L1 vs L2 性能对比:
| 操作 | L1 内存 | L2 Redis | 数据库 |
|---|---|---|---|
| 响应时间 | < 0.1ms | 3-7ms | 50-200ms |
| QPS 上限 | 100万+ | 10万+ | 5000 |
| 数据一致性 | 弱(5秒延迟) | 中 | 强 |
L1 的 TTL 为什么设置 5 秒
TTL 设置太长会有问题。如果设置 30 秒,8 个实例各自缓存,用户可能看到差 30 秒的数据。如果设置 60 秒,库存变化频繁,1 分钟延迟太长,用户体验差。
但太短也不行。0.5 秒的话,缓存几乎没用,频繁失效。1 秒的话,命中率不够高,L2 压力还是大。
5 秒是个平衡点。用户连续刷新页面,间隔通常小于 5 秒,大部分能命中 L1。最多 5 秒的延迟,对商品基础信息(名称、图片、价格)来说是可以接受的。而且每个实例各自缓存,不需要同步。
L2 缓存为什么要回写 L1
L1 miss 后查 L2,如果命中了,要把数据写回 L1,下次请求直接从 L1 返回。
不回写的后果:每次都要访问 Redis,L1 形同虚设。第一次请求 L1 miss 查 L2,第二次请求(0.5秒后)还是 L1 miss 查 L2,第三次请求(0.8秒后)依然 L1 miss 查 L2。
回写后:第一次请求 L1 miss 查 L2 并回写 L1,之后 0.5 秒、0.8 秒的请求都直接命中 L1。L1 命中率从 0% 提升到 90%+。
多实例下的 L1 一致性
8 个 Node.js 实例,每个实例都有独立的 L1 缓存。
场景:商品 123 原本库存 100 件,有用户下单后剩余 95 件。
时刻 T0:
实例1 L1 缓存:商品 123 库存 100
实例2 L1 缓存:商品 123 库存 100
时刻 T1(有人下单):
数据库:库存更新为 95
Redis L2:缓存失效(主动删除或 TTL 过期)
时刻 T2(用户刷新页面):
用户 A 打到实例1:L1 命中,返回库存 100(旧数据)
用户 B 打到实例2:L1 命中,返回库存 100(旧数据)
时刻 T3(5秒后,L1 过期):
用户 A 刷新,打到实例1:L1 miss → L2 miss → 查数据库 → 返回库存 95
用户 B 刷新,打到实例2:L1 miss → L2 miss → 查数据库 → 返回库存 95
5 秒的不一致窗口,对商品基础信息可接受。用户看到的商品名称、图片、价格晚 5 秒更新,感知不强。
但对库存这种高频变化的数据,5 秒延迟太长了。所以库存不走 L1,只走 L2(这部分会在下一篇文章详细讲)。
布隆过滤器
讨论方案时,我们也考虑过:“要不要加布隆过滤器,防止缓存穿透?“,但评估后决定不用,原因后面我们讲。这里先简单介绍一下布隆过滤器的原理和适用场景。
原理
布隆过滤器解决什么问题:
三级缓存上线后,L1 + L2 已经挡住了 99% 的请求。但剩余 1% 打到数据库的请求中,有一部分是查询不存在的商品。
比如用户输入错误的商品 ID,或者恶意遍历:
GET /product/999999 (商品不存在)
GET /product/999998 (商品不存在)
GET /product/999997 (商品不存在)
这些请求:
- L1 miss(内存没有)
- L2 miss(Redis 没有)
- 打到数据库查询,返回空
虽然只占 1% 请求的一部分(比如 0.1%),但如果是恶意攻击,可能瞬间变成 10%、20%,数据库扛不住。
布隆过滤器的作用:在 L2 和数据库之间加一道拦截,快速判断”这个商品 ID 一定不存在”,直接返回,不查数据库。
graph LR Request[请求] --> L1[L1 内存] L1 -->|miss| L2[L2 Redis] L2 -->|miss| Bloom[布隆过滤器] Bloom -->|一定不存在| Return[直接返回 null] Bloom -->|可能存在| DB[查数据库] style Bloom fill:#e5dbff,stroke:#5f3dc4,stroke-width:2px
核心特性:
- 如果返回 NO:商品一定不存在,直接返回,不查数据库
- 如果返回 YES:商品可能存在(有误判),继续查数据库确认
工作原理:
布隆过滤器本质是一个很长的位数组(比如 10000 个位置),初始全是 0。
添加商品时,比如商品 ID 是 123:
- 用 3 个哈希函数计算:
hash1(123) = 5,hash2(123) = 89,hash3(123) = 456 - 把位数组的第 5、89、456 位置设为 1
再添加商品 456:
- 计算:
hash1(456) = 12,hash2(456) = 89,hash3(456) = 777 - 把位数组的第 12、89、777 位置设为 1(注意第 89 位已经是 1 了)
查询商品时,比如查询 789 是否存在:
- 计算:
hash1(789) = 5,hash2(789) = 100,hash3(789) = 456 - 检查位数组的第 5、100、456 位置
- 第 5 位是 1(商品 123 设置的),第 456 位是 1(商品 123 设置的),但第 100 位是 0
- 结论:商品
789一定不存在(因为如果存在,第 100 位应该被设为 1)
再查询 999:
- 计算:
hash1(999) = 5,hash2(999) = 89,hash3(999) = 456 - 检查位数组的第 5、89、456 位置,全都是 1
- 但这些 1 可能是其他商品(123 和 456)设置的
- 结论:商品
999可能存在,需要查数据库确认
为什么会有误判:
多个商品共用同一个位数组,不同商品可能碰巧设置了相同的位置。查询一个不存在的商品时,如果它计算出来的位置刚好都被其他商品设置过,就会误判为”可能存在”。
但如果有任何一个位置是 0,说明这个商品从来没被添加过,一定不存在。
代码示例:
const { BloomFilter } = require('bloom-filters')
const bloom = new BloomFilter(10000, 4)
// 服务启动时初始化
async function initBloomFilter() {
const result = await pgPool.query('SELECT id FROM products')
result.rows.forEach(row => bloom.add(row.id))
console.log(`布隆过滤器已加载 ${result.rows.length} 个商品`)
}
async function getProductWithBloom(id) {
const l1Data = L1.get(id)
if (l1Data) return l1Data
const l2Data = await L2.get(`product:${id}`)
if (l2Data) {
const parsed = JSON.parse(l2Data)
L1.set(id, parsed)
return parsed
}
// 布隆过滤:一定不存在直接返回
if (!bloom.has(id)) return null
// 可能存在,查数据库确认
const result = await pgPool.query('SELECT * FROM products WHERE id = $1', [id])
if (result.rows.length === 0) return null
const dbData = result.rows[0]
await L2.setex(`product:${id}`, 300, JSON.stringify(dbData))
L1.set(id, dbData)
return dbData
}布隆过滤器的数据同步
布隆过滤器需要和数据库保持同步,这是维护成本的主要来源。
新增商品:必须同步添加到布隆过滤器
app.post('/api/product', async (req, res) => {
const { name, price } = req.body
const result = await pgPool.query(
'INSERT INTO products (name, price) VALUES ($1, $2) RETURNING *',
[name, price]
)
const product = result.rows[0]
bloom.add(product.id) // 同步到布隆
await L2.setex(`product:${product.id}`, 300, JSON.stringify(product))
res.json(product)
})删除商品:布隆过滤器无法删除,只能定期重建
app.delete('/api/product/:id', async (req, res) => {
await pgPool.query('DELETE FROM products WHERE id = $1', [req.params.id])
await L2.del(`product:${req.params.id}`)
// 布隆无法删除,只能定期重建
res.json({ success: true })
})
// 每天凌晨 3 点重建布隆过滤器
const schedule = require('node-schedule')
schedule.scheduleJob('0 3 * * *', async () => {
const newBloom = new BloomFilter(10000, 4)
const result = await pgPool.query('SELECT id FROM products')
result.rows.forEach(row => newBloom.add(row.id))
bloom = newBloom
// 持久化到 Redis,方便其他实例加载
await L2.set('bloom:products', JSON.stringify(bloom.saveAsJSON()))
})维护成本总结:
- 新增商品:每次都要同步,代码耦合
- 删除商品:无法真正删除,只能定期重建
- 多实例:需要通过 Redis 共享
- 重启恢复:需要重新加载
效果:如果使用布隆过滤器,99% 的无效请求会被挡住,数据库 QPS 可以降到 20 以下。
布隆过滤器的适用场景
| 场景 | 为什么需要布隆 | 不用布隆的后果 |
|---|---|---|
| 缓存命中率低(< 80%) | 大量请求穿透到数据库 | 数据库压力大,可能被打挂 |
| 恶意查询(如遍历 ID) | product:1, product:2… 缓存永远 miss | 成为 DDoS 攻击入口 |
| 数据稀疏(如短链服务) | 大部分短码不存在,缓存无法预热 | 每次都要查数据库确认不存在 |
| 数据库性能瓶颈 | 即使 1% 请求也扛不住 | 系统整体延迟飙升 |
为什么我们没用布隆
讨论方案时,有人提出加布隆过滤器,防止缓存穿透。但评估后,我们决定不用。
原因 1:缓存命中率已经很高
L1 命中率:92%
L2 命中率:7%
数据库查询:1%
布隆过滤器能拦截的只有这 1% 中”查不存在数据”的请求。监控显示,这部分只占总请求的 0.1%(大部分是正常查询,不是恶意遍历)。
ROI 太低:为了拦截 0.1% 的请求,增加了布隆过滤器的维护成本。
原因 2:限流已经解决了遍历攻击
我们在 API 层加了用户级限流:
const userLimiter = rateLimit({
windowMs: 1000,
max: 10, // 每秒最多 10 个请求
keyGenerator: (req) => req.user.id
})恶意用户遍历 ID 时,会触发限流,被直接拒绝。正常用户不会连续查询不存在的商品。
限流已覆盖场景:布隆过滤器的价值被限流替代了。
原因 3:维护成本太高
前面详细讲了布隆过滤器的数据同步问题,总结起来:
- 新增/删除商品都要同步
- 无法真正删除,只能定期重建
- 多实例需要通过 Redis 共享
这些维护成本,对我们 0.1% 的收益来说,完全不值得。
决策流程
graph TB Start((开始)) Start --> Q1{缓存命中率<br/>> 95%?} Q1 -->|YES| Simple[L1 + L2 已足够<br/>不用布隆] Q1 -->|NO| Q2{存在大量查询<br/>不存在数据?} Q2 -->|NO| Q3{数据库能扛住<br/>剩余流量?} Q2 -->|YES| UseBloom[使用布隆过滤器] Q3 -->|YES| Simple2[不用布隆<br/>保持简单] Q3 -->|NO| Optimize[方案选择] Optimize --> Opt1[加机器] Optimize --> Opt2[优化数据库] Optimize --> Opt3[用布隆过滤器] style Start fill:#dbeafe,stroke:#3b82f6,stroke-width:2px style Simple fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px style Simple2 fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px style UseBloom fill:#e5dbff,stroke:#5f3dc4,stroke-width:2px style Optimize fill:#ffe8cc,stroke:#d9480f,stroke-width:2px
方案复杂度对比
| 方案 | 复杂度 | 维护成本 | 适用场景 |
|---|---|---|---|
| L1 + L2 | 低 | 低 | 大多数业务,缓存命中率高 |
| L1 + L2 + 布隆 | 中 | 中 | 高并发、防穿透、数据稀疏 |
| L1 + L2 + 布隆 + 限流 | 高 | 高 | 超大规模、极端场景 |
什么时候真的需要布隆
布隆过滤器适合数据稀疏的场景,即大部分查询的数据不存在。
短链服务:生成短链 abc123,但用户可能随机访问任意 6 位字符,存在率只有 0.01%。每次都查数据库确认”不存在”,数据库压力大。
抢购系统:100 万用户抢 1000 件商品,99.9% 的请求会失败。布隆过滤器可以拦截”已售罄商品”的查询。
我们的促销系统不是这种场景,缓存命中率 99%,不需要布隆。
不要过度设计
很多业务 L1 + L2 就够用,甚至单级 Redis 缓存也足够。布隆过滤器不是银弹,只在特定场景下才有价值。
工程思维:先用简单方案,遇到瓶颈再优化。不要一开始就搞复杂架构。
我们的缓存命中率 99%,限流已经挡住了恶意请求,数据库压力不大。加布隆过滤器是过度设计,增加了维护成本,却没带来明显收益。
保持简单:能用两层缓存解决的问题,就不要加第三层。
但三级缓存上线后,我们遇到了另一个更棘手的问题:缓存与数据库不一致。用户投诉商品明明有库存,页面却显示”已售罄”。这个问题比性能更严重,因为它影响用户体验和交易准确性。详见下篇:缓存时效性问题。