第 42 章 / 共 57 章 · 约 3 分钟
设计模式:扩展写
读可以靠缓存和副本层层挡住,写却不行:每一次写都必须真正落到存储上,而且通常只有一个地方能接收它(主库、分区的 leader)。位置上报、聊天消息、点击日志、计数器这类写多的系统,扩展的办法是把写分散开、攒起来、或者换一种更擅长写的存储。
1. 先确认瓶颈
- 写入量到底有多大?每秒几千次写,一台调优过的 PostgreSQL 就能承受,不要过早分片。
- 瓶颈在哪:磁盘 fsync、锁竞争(热点行)、索引维护、还是网络?不同的瓶颈对应不同的解法。
2. 方案
2.1 垂直扩展和减少每次写的成本
- 更快的磁盘、更大的机器;去掉不必要的索引(每个索引都让写入多一份工作);批量提交。
- 这一步便宜,但有上限。
2.2 分片(水平分区)
- 按分片键把数据分到多台机器,每台只承担一部分写入。写吞吐随分片数近似线性增长。
- 分片键是关键:要让写入均匀分布(避免所有新数据都落在同一个分片),同时让最常见的查询只命中一个分片。
- 按时间范围分片:所有新写入集中在最新的分片上,形成热点。
- 按 user_id、conversation_id 哈希:分布均匀,同一实体的数据在一起。
- 用一致性哈希或者“很多逻辑分片映射到少量物理节点”,以后加机器只需迁移一部分数据。
- 代价:跨分片查询和事务变难,重新分片是大工程。
2.3 换成擅长写的存储
- LSM 树存储(Cassandra、DynamoDB、RocksDB)把随机写变成顺序写,单机写吞吐高,并且天生按分区键分布。
- 时序数据库适合指标和监控数据;追加写的日志(Kafka)适合先把事件记下来。
2.4 用队列削峰
- 突发的写入先进入队列或 Kafka,后端按自己能承受的速度消费写入。用户拿到的是“已接收”,而不是“已写入”。
- 适合可以异步的写(日志、分析事件、通知)。代价是延迟和最终一致,以及需要处理积压。
2.5 批量和预聚合
- 批量写:攒够 N 条或 T 毫秒再写一次,大幅减少每条写入的开销。
- 预聚合:点赞数、浏览量不需要每次 +1 都写库。先在内存、Redis 或流处理里按 key 聚合,每隔几秒写一次总数。一百万次 +1 可以变成一次 +1000000。
- 只保留最新值:位置上报这类数据,很多时候只需要最新位置,旧值覆盖即可;完整轨迹另外以追加的方式归档。
2.6 打散热点
- 热点 key(一个超大群聊、一个爆款商品的计数)会压垮单个分片。办法:把一个逻辑计数拆成 N 个子计数(
likes#0..likes#N-1),写时随机选一个,读时求和;或者给 key 加后缀分散,读时合并。
3. 怎么选
| 情况 | 推荐 |
|---|---|
| 每秒几千次写、需要事务 | 单个关系数据库,优化索引和批量提交 |
| 写入持续增长、按实体访问 | 按实体 id 哈希分片 |
| 海量追加写、按 key 加时间读 | 宽列存储(LSM) |
| 突发流量、可以异步 | 队列削峰 |
| 高频计数 | 预聚合、分片计数器 |
| 高频位置 / 状态更新 | 只存最新值,轨迹另外追加归档 |
4. 常见追问
- 按 created_at 分片有什么问题? 所有写入都打到最新的分片上,其他分片闲着。
- 分片之后怎么做跨分片查询? 尽量让查询带上分片键;必须跨分片的查询走异步构建的读模型(搜索索引、数仓)。
- 队列积压越来越多怎么办? 扩消费者(受分区数限制)、批量写入下游、降级非关键写入,并对上游限流。
- 预聚合会不会丢数据? 内存里的增量在崩溃时会丢;可以先写 Kafka 再聚合,或者接受计数类指标的少量误差。
5. 本站用到这个模式的案例
- 案例 5:网约车:每秒约 50 万次位置上报写入内存地理索引,完整轨迹经 Kafka 归档。
- 案例 4:即时通讯:消息写入宽列存储,按
(conv_id, month_bucket)分区控制分区大小。 - 案例 2:分布式限流器:每秒百万次计数器操作,按 key 分片到多个 Redis 节点。
- 案例 3:信息流:写时扇出会把一次发帖放大成几百次写,明星用户改为读时拉取。
- 案例 9:分布式键值存储:一致性哈希分区,LSM 引擎承担高写入。
- 案例 8:网络爬虫:用布隆过滤器做 URL 去重,网页内容批量写成大文件。