为什么数据库前面总要加一层缓存
数据库的单次查询成本以毫秒计,而一次内存读取以微秒计,两者差着三个数量级。绝大多数业务读多写少,同一份数据会被反复读取几百上千次,每次都打数据库纯属浪费。把热点数据放在 Redis 这样的内存库里,读请求直接命中缓存,数据库只承担写请求和缓存未命中时的回源——这就是缓存的基本价值。
但缓存不是万能的加速按钮,它带来的是一致性、可用性与成本的三方权衡。用不好,缓存会击穿数据库、会把脏数据放给用户、会在过期瞬间引发雪崩。这篇聚焦三件事:缓存怎么放、分布式锁怎么用、限流怎么落地。
- Cache Aside读时回填、写时失效,是最常用的缓存模式。
- 穿透/击穿/雪崩分别对应查不存在、热点过期、批量过期。
- 分布式锁用 set nx 加过期时间,看门狗自动续期。
- 限流令牌桶思想在 Redis 上以固定窗口或滑动窗口实现。
旁路缓存模式:Cache Aside 怎么写
Cache Aside 是最朴素也最常用的模式:读的时候先查缓存,没有就查数据库并回填缓存;写的时候更新数据库,再把缓存删掉。注意是"删缓存"而不是"更新缓存"——并发写时更新缓存容易写出脏数据,删掉之后下次读自然回填,简单且可靠。
// 读:缓存未命中才回源数据库
Object get(String key) {
Object v = redis.get(key);
if (v != null) return v;
v = db.query(key);
redis.setex(key, 300, v); // 顺手设置过期时间
return v;
}
// 写:先更库,再删缓存
void update(String key, Object nv) {
db.update(key, nv);
redis.del(key); // 删缓存,而不是更新缓存
}
为什么写之后要先更库再删缓存?反过来(先删缓存再更库)会在并发下出现:线程 A 删了缓存、线程 B 读时回填了旧值、线程 A 才写完数据库,于是缓存里长期是旧值。先更库再删缓存,窗口期短得多,且下次读会自然纠正。
缓存穿透、击穿与雪崩
这三个词经常被混着用,但成因完全不同。穿透是请求查一个根本不存在的数据,缓存永远命中不了,每次都打数据库——攻击者拿随机 id 刷接口就是典型。对策是缓存空值(短过期)或布隆过滤器挡一层。击穿是某个热点 key 在过期的瞬间被海量请求同时击穿,全部回源。对策是热点 key 永不过期、由后台异步刷新,或用分布式锁只放一个请求回源。雪崩是大量 key 在同一时刻集体过期,数据库瞬间被压垮。对策是过期时间加随机抖动,错开过期点。
| 问题 | 成因 | 对策 |
|---|---|---|
| 穿透 | 查不存在的数据 | 缓存空值 / 布隆过滤器 |
| 击穿 | 热点 key 瞬时过期 | 热点永不过期 / 单飞锁 |
| 雪崩 | 大批 key 同时过期 | 过期时间加随机抖动 |
分布式锁:set nx 与看门狗
单机锁在多实例部署下失效,跨实例互斥就要靠 Redis。核心命令是 set key value nx ex 10:key 不存在才设置(nx),并带 10 秒过期(ex),保证拿不到锁时不会死等、持有者宕机后锁能自动释放。value 要放一个唯一标识,释放锁时先比对再删,防止误删别人的锁。
# 加锁:唯一请求标识,10 秒自动过期
SET lock:order:1001 uuid-xxxx NX EX 10
# 释放锁:Lua 脚本保证"比对+删除"原子执行
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
业务执行时间超过锁的过期时间怎么办?成熟框架(如 Redisson)的"看门狗"会在锁快过期时自动续期,持有者活着就一直延长,宕机才到期释放。手写锁时不要自己造续期轮询,直接用框架更稳。
接口限流:令牌桶在 Redis 上的实现
限流保护的是后端不被突发流量打垮。令牌桶的思想:以固定速率往桶里放令牌,每个请求先取一个令牌,取不到就拒绝。在 Redis 上用一个 key 记录剩余令牌数和上次补充时间,每次请求到来时按时间差先补令牌、再尝试扣减,整个计算用 Lua 脚本保证原子。
-- KEYS[1]=限流 key;ARGV=[速率, 桶容量, 当前时间]
local rate, cap, now = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local data = redis.call("hmget", KEYS[1], "tokens", "ts")
local tokens = tonumber(data[1]) or cap
local ts = tonumber(data[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate) -- 按时间差补令牌
if tokens < 1 then return 0 end -- 拒绝
tokens = tokens - 1
redis.call("hmset", KEYS[1], "tokens", tokens, "ts", now)
redis.call("expire", KEYS[1], 60)
return 1
比固定窗口更平滑的是滑动窗口:把一分钟切成若干小格子,统计最近几格的请求数。网关层(如 Nginx、Spring Cloud Gateway)也内置了限流,入口先挡一道,应用内再挡一道,分层防护。
缓存一致性与过期策略
缓存与数据库强一致几乎做不到,工程上追求的是最终一致的时间窗口尽可能短。除了"先更库再删缓存",还可以加一步:写库后发送一条延迟消息,过几百毫秒再删一次缓存,兜住并发回填旧值的极小概率窗口。对一致性要求极高的数据,干脆不走缓存,或走"读写都经过缓存、缓存作为唯一数据源"的模式。
过期时间要分档:热点数据短过期加随机抖动,配置类数据长过期,空值超短过期。永远不要给所有 key 设同一个过期时间,那是雪崩的温床。
多级缓存与热点治理
极端热点数据连 Redis 都扛不住每秒数万次读取,可以在应用本地再放一层缓存(如 Caffeine),组成"本地缓存 + Redis + 数据库"的三级结构。本地缓存命中最快,但多实例间不一致,因此只放读极频繁、允许秒级延迟的热点,并配短过期与主动失效通知。
热点治理还包括:识别热点 key(监控 Redis 的热 key 分布)、对热点做只读副本或就近节点、对超热点接口直接在网关层返回缓存结果,彻底不进应用。
常见坑清单
最后列几条踩过的坑:
- 缓存空值别设太长过期,否则脏数据停留久;也别忘设过期,否则永久占位。
- 分布式锁 value 必须唯一,释放锁用 Lua 原子比对,别直接 del。
- 限流 key 要带用户或接口维度,别一把尺子量所有接口。
- 批量预热时错开过期时间,别让新缓存一起到期。
- 监控 Redis 的命中率、热 key 与慢查询,缓存出问题往往先在监控上显形。
缓存治理的本质是在"快"和"对"之间找平衡:热点数据放心快、关键数据谨慎对,把每一类数据的过期与一致性策略想清楚,缓存就不会变成事故来源。