缓存设计
选了 302 重定向后,每次跳转都要查数据库。起初还好,但随着用户增多,问题开始显现。
某天早上,监控告警响了。数据库 CPU 使用率飙到 60%。
我打开日志,看到密密麻麻的查询:
数据设计要点
- 查询目标是用短码快速找到目标链接、状态或统计结果,避免在跳转路径上做大范围扫描。
- 落到
urls表上就是走idx_short_code这条索引:短码是等值条件,命中后只取回那一行的目标长 URL。- 跳转路径上不做范围查询,也不做全表扫描;统计维度的查询各自走统计表,不混进跳转链路。
短链接服务的访问特点,我总结了一下:
读取 vs 写入比例:1000:1
典型的访问模式:
- 创建短链接:1 次
- 重定向跳转:1000 次+
一个短链接被创建后,可能被访问成千上万次。如果每次都查数据库,数据库很快就扛不住。
我知道,不能再每次都查数据库了。
第 3 版:缓存加速跳转
跳转读流量远高于创建流量,Redis 承担大部分短码解析。
入口
应用
缓存
存储
加入缓存后的跳转链路
redirect_cached跳转请求进入短链服务
优先读取 Redis / 本地缓存
直接拿到长 URL 和状态
点击事件进入队列,不阻塞跳转
低延迟完成跳转
缓存上线后的跳转表现
cached跳转读流量增长
Redis 命中后明显下降
热点短码被缓存吸收
作为前端出身的开发者,这是我第一次接触 NoSQL。
我花了一天学习 Redis。它和 MySQL 完全不同:
MySQL(关系型数据库):
- 数据存在磁盘上
- 查询需要解析 SQL、优化执行计划
- 单次查询耗时:10-50ms
- 适合复杂查询、事务
Redis(内存数据库):
- 数据存在内存中
- 简单的 Key-Value 操作
- 单次查询耗时:0.1-2ms
- 适合高速读写
我在本地启动 Redis,尝试基本操作:
落地思路
- 三个动作就够用了:写入用
SET带上过期时间、读取用GET、删除用DEL。- 键值都是字符串:键放短码,值放目标长 URL,正好对应「短码 → 目标 URL」这个映射。
- 写入时把过期时间一起带上,避免「先写后设 TTL」两步之间留下一个永不过期的键。
- 兜底是所有键都带 TTL:读到没设过期的键就顺手补一个,不让脏键长期占着内存。
简单,直接,快速。
这就是我需要的。
最简单的方案:查询前先查 Redis,命中则直接返回,未命中则查数据库并回填缓存。
架构图:
用户请求
↓
┌──────────────┐
│ API 服务 │
└──────────────┘
↓
┌───────────┴───────────┐
↓ ↓
┌──────────────┐ ┌──────────────┐
│ Redis 缓存 │ ←─── │ MySQL │
│ (热数据) │ 回写 │ (全量数据) │
└──────────────┘ └──────────────┘
这种模式叫 Cache-Aside(旁路缓存):
- 先读缓存,命中直接返回
- 未命中读数据库
- 写入缓存,设置过期时间
这个流程拆开看:
落地思路
- 第一步读缓存:命中就直接返回目标 URL,整个跳转不回数据库。
- 第二步读数据库:未命中时用
idx_short_code定位到那一条记录,取出目标长 URL。- 第三步回写缓存:把刚读到的值写回 Redis,TTL 设成这一档数据的有效期。
- 回写这一步不能抛异常影响跳转:缓存写失败只记日志,这一次请求仍然用数据库的值正常返回。
为什么设置过期时间?
内存有限,不能无限存储。过期时间让不常用的数据自动清理。
落地思路
- TTL 决定这份数据最多会「旧」多久:TTL 越长,缓存越省数据库,但目标 URL 的改动生效得越慢。
- 空值缓存用短 TTL(5 分钟),正常链接用长 TTL(1 小时),两类数据不能用同一个值。
- TTL 到期后键自动被清理,内存占用不会随着短链接数量的增长而无限上涨。
- 兜底是回源时重新写 TTL:每次缓存未命中回填都重置过期时间,热点链接自然续期。
缓存 Key 的设计:
落地思路
- 键的格式是
url:<短码>:前缀标明用途,避免和别的业务键混在一起。- 值只存目标长 URL 这一个字符串,不塞 JSON、不塞整条记录——跳转只需要目标地址,多存都是浪费内存。
- 缓存空值时用固定标记值(比如
__NULL__)表示「查过但不存在」,不能存空字符串和「没写过」混淆。- 兜底是键名里不掺用户信息:短码本身就是索引,键太散会让 Redis 的键空间变得难以清理。
上线后,我盯了一天监控数据。
结果超出预期:
| 指标 | 上线前 | 上线后 | 提升 |
|---|---|---|---|
| 缓存命中率 | - | 92% | - |
| 平均响应时间 | 50ms | 8ms | 84% ↓ |
| 数据库查询量 | 10万次/天 | 8000次/天 | 92% ↓ |
| 数据库 CPU | 60% | 15% | 75% ↓ |
92% 的请求从缓存返回,只有 8% 需要查数据库。
我用压测结果验证这个判断:
落地思路
- 压测要复刻真实访问形态:绝大多数请求打在少数几个热短码上,而不是均匀打到所有链接。
- 对照组是不带缓存的直查版本,实验组是带 Redis 的版本,两组跑同一段请求序列。
- 看的三个指标和上面那张表一致:命中率、平均响应时间、数据库查询量。
- 期望命中率和数据库查询量的降幅都对得上上线后那组数:命中率 92%,数据库查询量降 92%。
数据库压力骤降,系统稳定了。
但缓存带来了新问题。
某天用户反馈:“我修改了原始 URL,但跳转还是旧的地址。”
我检查数据库,确实是新地址。但 Redis 里还是旧的。
问题根源:用户修改了数据,但缓存没有同步更新。
时间线:
1. 短链接 a1b2c3 指向 https://old-url.com
2. 缓存存储:url:a1b2c3 = https://old-url.com(1 小时过期)
3. 用户修改:a1b2c3 改为指向 https://new-url.com
4. 数据库已更新,但缓存还是旧的
5. 1 小时内,所有请求都跳转到旧地址
这是典型的缓存一致性问题。
解决方案 1:主动失效
修改数据时,主动删除缓存:
落地思路
- 修改目标 URL 时,数据库更新和缓存删除放在同一个流程里:先写数据库,再删
url:<短码>这个键。- 删除而不是更新缓存值:下次请求缓存未命中,会从数据库读回最新值并重新回填。
- 删除只在目标 URL 真正变化时才做,其他字段的修改不动缓存,避免无谓的缓存失效。
- 兜底是删除失败要记日志并重试,不能默默跳过——漏删一次,这条链接就会在 TTL 内一直跳旧地址。
下次查询时,缓存未命中,从数据库加载最新数据。
解决方案 2:双写
修改数据时,同时更新缓存:
落地思路
- 双写是「改数据库的同时改缓存」,把两步合成一个流程执行。
- 好处是缓存里立刻就是新值,用户下一次访问不用等一次回源,修改感知是零延迟的。
- 风险是两步不是原子的:数据库写成功而缓存写失败时,缓存里留着旧值,一致性反而更差。
- 兜底是双写失败时回滚数据库那一步,或者干脆退回到主动失效的方案。
我选择主动失效
原因:
- 更简单:不用考虑新旧值
- 更安全:数据库是唯一真相
- 更灵活:修改后可能不会立即访问,删除缓存节省内存
删除操作也有个问题:如果删除失败怎么办?
我加了重试:
落地思路
- 删除缓存失败通常是 Redis 短暂不可用,这类失败几乎都是可以重试的。
- 重试要有上限:固定次数加一点随机退避,避免 Redis 恢复前把所有修改请求都堵在这里。
- 重试仍然失败就把这个键的删除记成待办,等 Redis 恢复后由后台任务补删。
- 兜底是数据本身带上更新时间:即使缓存删不掉,也能判断出缓存里的值是不是旧的。
解决了缓存一致性,又出现了新问题。
监控显示大量短链接码不存在:
访问不存在的短链接:
- url:xyz123 → 缓存没有 → 查数据库 → 也没有
- url:abc999 → 缓存没有 → 查数据库 → 也没有
- url:random → 缓存没有 → 查数据库 → 也没有
有人用随机短链接码恶意攻击。
每次都穿透缓存,打到数据库。这就是缓存穿透。
解决方案:缓存空值
落地思路
- 查不到短码时,往缓存里写一个空值标记(
__NULL__),TTL 设成 5 分钟。- 之后对同一个短码的请求都命中这个空标记,直接返回 404,不再打到数据库。
- 空值必须能和「键不存在」区分开:用固定标记值而不是空字符串,否则读不到键和读到空值会混成一回事。
- 兜底是 TTL 不能太长:5 分钟之后这个键自动过期,用户真的创建了这个短码时能及时生效。
效果:
| 场景 | 无空值缓存 | 有空值缓存 |
|---|---|---|
| 1000 次攻击 | 1000 次 DB 查询 | 1 次 DB 查询 |
| 数据库压力 | 100% | 0.1% |
空值缓存过期时间设置为 5 分钟:既能防护,又不会太久影响正常创建。
还有一个隐患:大量缓存同时过期。
场景:
- 10:00 批量创建 1000 个短链接,缓存过期时间都是 1 小时
- 11:00 这 1000 个缓存同时过期
- 11:00:01 突然 1000 个请求打到数据库
- 数据库崩溃
这就是缓存雪崩。
解决方案:随机过期时间
落地思路
- 写缓存时在基准 TTL 上加一个随机偏移,让同一批创建的短链接不会在同一秒集体到期。
- 偏移范围要覆盖住批量创建的规模:比如基准 1 小时,偏移在几分钟量级上随机。
- 集体过期被拆散之后,回源请求摊到一段时间里,数据库不会在某一秒被打穿。
- 兜底是回源侧本身也要有限流:即使真的撞上回源高峰,也不让数据库被瞬时流量压垮。
每个缓存的过期时间都有差异,避免同时失效。
我把所有方案整合到一起:
落地思路
- 读路径固定为「先查缓存 → 未命中查数据库 → 回填缓存」,三级之间没有别的分支。
- 写路径固定为「先写数据库 → 再删缓存」,改动只发生在这一条路上,不做缓存双写。
- TTL 分两档:正常链接 1 小时、空值 5 分钟,热点链接靠续期保持长期驻留。
- 兜底是缓存不可用时读路径自动退化成直查数据库——响应变慢,功能不中断。
这套方案覆盖了:
- Cache-Aside 模式:先查缓存,未命中查数据库
- 缓存空值:防止穿透
- 随机过期:防止雪崩
- 主动失效:保证一致性
| 组件 | 作用 | 性能 |
|---|---|---|
| Redis 缓存 | 热点数据存储 | ~1ms |
| MySQL | 全量数据存储 | ~50ms |
| 空值缓存 | 防穿透 | ~1ms |
| 随机过期 | 防雪崩 | - |
核心要点:
- Cache-Aside 模式最常用
- 缓存空值防止穿透
- 随机过期时间防止雪崩
- 修改数据时主动删除缓存
缓存解决了大部分性能问题。数据库 CPU 从 60% 降到 15%,响应时间从 50ms 降到 8ms。
但 301 和 302 的选择,在有了缓存后,又有了新的考量……
练习 1
为什么缓存空值可以防止缓存穿透?空值缓存的过期时间应该设置多久?
解题思路:
缓存穿透的本质是”查不存在的键时请求全部落到数据库”。防穿透的关键是让第二次以后的查询不再打库,而空值缓存正好能做到。但空值有过期时间要定,这个时间定多少取决于两个相反的约束:防护窗口要够长,而用户真的创建出同名短码时又要能较快生效。
详细步骤:
缓存空值防穿透原理:
攻击场景:
- 攻击者访问不存在的 short_code: "xyz123"
- 缓存中没有 → 查数据库 → 也没有
- 每次都打到数据库
防御方案:
- 第 1 次查询后,缓存空值:redis.setex("url:xyz123", 300, "__NULL__")
- 第 2-100 次查询:直接从缓存返回 NULL
- 300 秒后空值过期,下次查询才会再次访问数据库效果对比:
| 场景 | 无空值缓存 | 有空值缓存 |
|---|---|---|
| 1000 次攻击 | 1000 次 DB 查询 | 1 次 DB 查询 |
| 数据库压力 | 100% | 0.1% |
空值缓存过期时间:
推荐:5-10 分钟
理由:
- 不能太短:否则起不到防护作用
- 不能太长:如果用户真的创建了这个短链接,需要能及时生效
- 折中方案:5-10 分钟,平衡防护和灵活性
落地思路
- 空值 TTL 取 5 分钟是折中:太短则防护失效,太长则用户真的创建了同名短码时要等很久才能生效。
- 具体取值上 5-10 分钟这个区间都可以,取决于「用户创建短码后多久会有人访问」的业务节奏。
- 空值标记要用固定值(比如
__NULL__)而不是空字符串,否则读侧分不清「键不存在」和「查过没有」。- 兜底是即使 TTL 配错导致空值残留太久,也能在用户创建同名短码时主动删掉这个键。
最终答案:
缓存空值可以防穿透:攻击者访问不存在的短码时,第一次查库无结果后把空值以固定标记(例如 NULL)写入缓存并设 300 秒过期,之后的请求直接从缓存读到空值不再打库,1000 次攻击只产生 1 次数据库查询。空值过期时间推荐 5 到 10 分钟:太短则起不到防护作用,太长则用户真的创建了这个短码后要等很久才能生效,5 到 10 分钟是对两者的折中,具体取值取决于用户创建短码后多久会有人访问。空值标记必须用固定值而不是空字符串,否则读侧分不清”键不存在”和”查过没有”;即使 TTL 配错导致空值残留,也要在用户创建同名短码时主动删掉这个键。
练习 2
设计一个支持热点数据自动识别的缓存策略。
解题思路:
热点识别的依据是访问次数,问题在于热度会变化,所以 TTL 不能一刀切。关键设计点是分档而不是取平均:冷数据要快速释放内存,热数据要靠续期长期驻留,两头都得顾到。
详细步骤:
落地思路
- 热度判定的依据是访问次数:点击数低于 100 的按冷数据处理给 5 分钟 TTL,100-1000 给 1 小时,超过 1000 给 7 天。
- 冷数据快速过期释放内存,热数据靠「每次访问自动续期」长期驻留,命中率因此能拉到 99.9%。
- TTL 分档要在写入缓存那一刻决定,依据是该短码累计的点击数(从点击统计里取)。
- 兜底是给热点判定设上界:点击数可能因刷量虚高,所以热数据最长只驻留 7 天。
策略说明:
-
冷数据(点击
< 100):5 分钟 TTL- 不常访问,快速释放内存
-
温数据(点击
100-1000):1 小时 TTL- 有一定访问,适中缓存
-
热数据(点击
> 1000):7 天 TTL- 高频访问,长期缓存
- 每次访问自动续期
效果:
- 热数据命中率:99.9%
- 内存使用优化:冷数据不占用长期内存
- 自适应:根据访问模式自动调整
最终答案:
按累计点击数给缓存分三档 TTL:点击数低于 100 的冷数据给 5 分钟,过期快、不长期占用内存;点击数 100 到 1000 的温数据给 1 小时;有 1000 以上的热数据给 7 天并且每次访问自动续期,使热数据命中率可达 99.9%。TTL 分档要在写入缓存那一刻决定,依据是该短码的累计点击数(从点击统计里取)。同时要给热点判定设上界:点击数可能因刷量虚高,所以热数据最长只驻留 7 天。整体效果是冷数据快速释放内存、热数据长期驻留、缓存策略随访问模式自动调整。
发现内容有问题?
告诉我们哪里需要改进。
