cswork / 系统设计

第 41 章 / 共 57 章 · 约 3 分钟

设计模式:扩展读

本页目录
  1. 1. 第一步:让数据库本身读得快
  2. 2. 第二步:读副本
  3. 3. 第三步:缓存
  4. 4. 第四步:预计算
  5. 5. 第五步:分片
  6. 6. 热点读
  7. 7. 常见追问
  8. 8. 本站用到这个模式的案例
  9. 章节测验

大多数产品读远多于写:一条短链接被点击上万次,一条帖子被几百万人看到,一个演出页面在开票前被反复刷新。读多写少的系统,扩展的思路是一层层地把读请求挡在数据库之前,并且让剩下的读尽量便宜。顺序很重要:先做便宜的,再做复杂的。

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. 本站用到这个模式的案例

章节测验

5 道选择题
  1. 一个读多写少的接口变慢了,查看后发现查询在做全表扫描。第一步应该做什么?
  2. 用户修改了个人资料,刷新后看到的还是旧资料。读请求走异步复制的副本,前面还有缓存。最合理的组合是?
  3. 信息流的时间线读取很慢,因为每次都要查关注列表、拉取每个人的最新帖子再合并。哪种做法属于“预计算”?
  4. 开票前一分钟,一个演出详情页的读请求暴涨,单个 Redis 节点被这一个 key 打满。哪个办法无效?
  5. 什么情况下继续增加读副本的效果会越来越差?

章节讨论

正在加载讨论…