促销系统崩溃后,我们加了限流和熔断。系统不崩了,但数据库压力还是很大。

高峰期每秒 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.1ms3-7ms50-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%,限流已经挡住了恶意请求,数据库压力不大。加布隆过滤器是过度设计,增加了维护成本,却没带来明显收益。

保持简单:能用两层缓存解决的问题,就不要加第三层。

但三级缓存上线后,我们遇到了另一个更棘手的问题:缓存与数据库不一致。用户投诉商品明明有库存,页面却显示”已售罄”。这个问题比性能更严重,因为它影响用户体验和交易准确性。详见下篇:缓存时效性问题