为什么数据库前面总要加一层缓存

数据库的单次查询成本以毫秒计,而一次内存读取以微秒计,两者差着三个数量级。绝大多数业务读多写少,同一份数据会被反复读取几百上千次,每次都打数据库纯属浪费。把热点数据放在 Redis 这样的内存库里,读请求直接命中缓存,数据库只承担写请求和缓存未命中时的回源——这就是缓存的基本价值。

但缓存不是万能的加速按钮,它带来的是一致性、可用性与成本的三方权衡。用不好,缓存会击穿数据库、会把脏数据放给用户、会在过期瞬间引发雪崩。这篇聚焦三件事:缓存怎么放、分布式锁怎么用、限流怎么落地。

本文核心知识点
  • Cache Aside读时回填、写时失效,是最常用的缓存模式。
  • 穿透/击穿/雪崩分别对应查不存在、热点过期、批量过期。
  • 分布式锁用 set nx 加过期时间,看门狗自动续期。
  • 限流令牌桶思想在 Redis 上以固定窗口或滑动窗口实现。
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 设同一个过期时间,那是雪崩的温床。