第 41 章 / 共 57 章 · 约 3 分钟
设计模式:扩展读
大多数产品读远多于写:一条短链接被点击上万次,一条帖子被几百万人看到,一个演出页面在开票前被反复刷新。读多写少的系统,扩展的思路是一层层地把读请求挡在数据库之前,并且让剩下的读尽量便宜。顺序很重要:先做便宜的,再做复杂的。
1. 第一步:让数据库本身读得快
- 索引:按查询的 WHERE 和 ORDER BY 建复合索引,避免全表扫描。一个缺失的索引能让查询慢上千倍,这是最便宜的优化。
- 只取需要的:分页(游标分页优于 OFFSET)、只查需要的列、避免 N+1 查询(批量取)。
- 反范式化:把经常一起读的数据冗余存放(例如在帖子表里存作者名和评论数),用写入时多做一点换读取时少 JOIN。
2. 第二步:读副本
- 主库负责写,多个只读副本分担读。读吞吐随副本数扩展,几乎不用改代码。
- 代价是复制延迟:刚写完马上读可能读到旧数据。对“自己刚写的东西”要保证读己之写:写后短时间内读主库,或者读到写入的 LSN 之后的副本。
- 副本也有上限:每个副本都要重放全部写入,写入量很大时副本跟不上。
3. 第三步:缓存
- 应用缓存:Redis 或 Memcached 做 cache-aside,热门数据命中率常常在 90% 以上,读延迟降到亚毫秒。
- CDN 和边缘缓存:静态资源、公开的页面和接口响应(
Cache-Control: public, max-age=...)在离用户最近的节点返回,连你的服务器都到不了。 - 客户端缓存:浏览器和 App 本地缓存,加上 ETag /
If-None-Match条件请求。 - 进程内缓存:最热的几千个 key 放在应用进程内存里,连 Redis 的网络往返都省掉;代价是各实例之间不一致,TTL 要短。
- 缓存要处理的问题:失效(写库后删缓存)、击穿(single-flight)、雪崩(TTL 抖动)、热点 key(本地缓存或多副本)。
4. 第四步:预计算
- 物化视图 / 读模型:把读需要的结果提前算好存起来。信息流的“写时扇出”就是把每个用户的时间线预先写好,读时一次取出。
- 按查询形态建专门的存储:全文搜索交给 Elasticsearch,自动补全预计算“前缀 → Top-K”,统计报表交给数仓。主库只做它擅长的事。
- CQRS:写模型和读模型分开,通过事件(CDC 或 Kafka)同步。读模型可以有多个,各自为一种查询优化。代价是最终一致和更多的组件。
5. 第五步:分片
- 当数据量或者需要缓存的数据大到一台机器放不下,或者写入也随之增长时,按 key 分片。分片对写的帮助更大,详见扩展写。
6. 热点读
- 某个 key 的读流量超过一个缓存节点的能力(明星的帖子、开票的演出页):
- 进程内本地缓存,短 TTL;
- 把 key 复制到多个缓存节点(
key#1..key#N),随机读一个; - 请求合并:同一时刻对同一 key 的多个回源请求合并成一个;
- 能缓存在 CDN 的尽量放到 CDN。
7. 常见追问
- 缓存命中率很低怎么办? 看访问分布是否真的有热点、key 设计是否太细(每个用户一份),TTL 是否太短。
- 数据更新后用户还看到旧的? 写后删缓存;对作者本人走读己之写;CDN 缓存用版本化的 URL 或主动刷新。
- 读副本加到多少个? 受限于主库的写入量(每个副本都要重放)和复制延迟;大多数时候缓存比继续加副本更有效。
- 什么时候需要单独的读模型? 查询形态和写入形态差别很大(搜索、信息流、聚合报表),在主库上建索引也解决不了的时候。
8. 本站用到这个模式的案例
- 案例 1:短链接服务:重定向在 CDN、Redis 两层缓存挡掉绝大多数读,读写服务分开部署。
- 案例 3:信息流:写时扇出预先生成时间线,明星用户改为读时拉取,再批量补全帖子内容。
- 案例 11:搜索自动补全:预计算每个前缀的 Top-K,不分人群的响应可以被浏览器和 CDN 缓存。
- 案例 7:视频流平台:视频分片经 CDN 分发,源站只承担很小一部分流量。
- 案例 12:票务抢购:活动页和静态座位图放在 CDN 上,搜索走 Elasticsearch。