蚁阅海量文章存储方案

蚁阅 是一个 RSS 阅读服务, 全文阅读是其中一个很实用的功能,可以为读者提供流畅和一致的阅读体验。

早期在存储方面没有考虑太多,直接把文章信息和全文存储在 PostgreSQL 里面,随着订阅量越来越多, 这个表也越来越大(每个订阅至多保留 1K 篇文章),不久的将来会达到几千万这个量级。这会带来两个问题:

  1. 单表/单数据库的处理能力受限于单块磁盘的 IO 性能,会成为瓶颈。
  2. 数据备份和恢复会越来越慢。

要解决 #1,需要对数据做分片,使用多个磁盘。要解决 #2,需要支持增量备份和恢复。

使用云厂商的对象存储方案,可以很好的解决这两个问题。 但是蚁阅有点特殊,首先我不希望绑定到任何云厂商,说不定哪天就被封了,这完全不可控; 其次蚁阅要支持独立部署,哪怕蚁阅服务没了,大家也能自己部署继续用这个产品。

不能使用云厂商的方案,那就找开源替代方案,调研下来大致有这些方案:

  1. Ceph
  2. MinIO
  3. SeaweedFS

Ceph 是重量型方案,对小项目来说太复杂了。
MinIO 比较新,部署相对比较简单,但架构和配置还是挺复杂的。
SeaweedFS 部署简单,架构也简单,基于 Facebook Haystack 设计。

SeaweedFS方案

我首先按 SeaweedFS 设计了一版存储方案。SeaweedFS 相当于 KV 存储,键称为 fid。 例如: 3,01637037d6,开头的 3 表示 Volume ID,中间的 01 是文件的 Key,用于定位文件, 后 8 位是 Cookie,用于防止猜解 URL,这里似乎用不上。Key 和 Cookie 都是 32 位整数,用 16 进制表示。

SeaweedFS 有 Master 节点和 Volume 节点,Master 负责管理 Volume ID 到 Volume 节点的映射关系,Volume 节点负责存储数据。 通常可以由 Master 分配 fid,保证全局唯一和数据平衡。也支持自定义生成规则。

实际上每个 Volume 存储为磁盘上一个文件,SeaweedFS 会在内存里维护 fid 到文件读写位置的映射, 实现每次查询和写入都只需要 1 次 IO 操作,同一个 Volume 的操作是串行的。

蚁阅使用自定义 fid 生成规则,由 feed_id 和 offset 唯一确定一篇文章,不依赖于 Master 节点:

story key: 64 bits
+----------+---------+--------+----------+
|     4    |   28    |   28   |    4     |
+----------+---------+--------+----------+
| reserve1 | feed_id | offset | reserve2 |
+----------+---------+-------------------+

reserve1 和 reserve2 是保留位,目前没有用到。

然后数据按 feed_id 范围分片,比如每 1K 个订阅的文章存储在一个 Volume,每个分片数据大约是 1G, SeaweedFS 每个 Volume 最大支持 32G。 范围分片很容易支持扩容,随着订阅量增长只需添加更多的 Volume 节点,不用数据迁移。

但范围分片可能会有数据不均匀,所以稍微改进一下,每 8 个 Volume 一组, 数据按 feed_id 范围分组,每 8K 个订阅一组,组内再哈希分片。 这样数据分布会比较均匀,扩容时就一次扩容一组 Volume。

性能分析

按这个方案实现之后,做了一些性能分析。

首先在阿里云 200G 高效云盘上,磁盘性能大约为 3000 IOPS。 使用 SeaweedFS 自带的 benchmark 命令,可以测到写入 QPS 接近 3000, 符合每次查询和写入都只需要 1 次 IO 操作,接近磁盘性能极限。

Concurrency Level:      16
Time taken for tests:   384.116 seconds
Complete requests:      1048576
Failed requests:        0
Total transferred:      1106812710 bytes
Requests per second:    2729.84 [#/sec]
Transfer rate:          2813.92 [Kbytes/sec]

Connection Times (ms)
              min      avg        max      std
Total:        0.5      5.8       107.9      3.5

Percentage of the requests served within a certain time (ms)
   50%      4.8 ms
   66%      6.4 ms
   75%      7.2 ms
   80%      7.9 ms
   90%     10.0 ms
   95%     12.4 ms
   98%     15.5 ms
   99%     18.2 ms
  100%    107.9 ms

但是蚁阅更新订阅时是批量写入文章,我发现这个操作很慢。
如果一个订阅有 50 篇文章,全部写入就需要 500 ms,而且并行发请求效果和串行耗时一样。

所以问题可能是 Volume 数量影响了并行性能,我用 aiohttp 写了个脚本测试不同的 Volume 数量和客户端并发数对性能的影响。(详细结果附在文末)

结论是:增大并发数或 Volume 数对性能提升非常小,性能取决于磁盘,而单个磁盘 IO 是串行的。

PostgreSQL方案

从监控数据发现,SeaweedFS 方案比改写前慢了 3 倍左右。 猜想是由于蚁阅都是批量写入,而且数据都是连续的,PostgreSQL 会把一个事务里的 IO 操作合并,提升了性能。

因此我又设计了一版基于 PostgreSQL 的存储方案,表结构如下:

CREATE TABLE IF NOT EXISTS story_volume_0 (
    id BIGINT PRIMARY KEY,
    content BYTEA NOT NULL
)

主键 ID 按上文 story key 方式编码。一开始我尝试 feed_id, offset 联合主键,发现 Django 不支持, 遂改用自定义编码,效果是一样的,同时支持范围查找和范围删除。

数据分片同样用范围分片,只是分片大小设为了 64K,单表大约 1KW 行数据。
配置里指定 Volume ID 到 “数据库 + 表” 的映射关系,表会在首次使用时创建。
扩容只需部署好 PostgreSQL,然后增加映射关系即可。

写入操作,支持批量写入:

INSERT INTO story_volume_0 (id, content) VALUES (:id, :content)
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content

我用 asyncpg 简单实现了一版,然后做了个对比测试,单个写性能略好于 SeaweedFS, 但延迟略高于 SeaweedFS。(详细结果附在文末)

正式的实现用的是 SQLAlchemy + psycopg2,有连接池性能很好,实现起来也比较稳妥。
从监控数据上看,性能和改写前基本持平,所以没有再对比批量写的性能了。

综合考虑,PostgreSQL 迁移成本较低,不用多维护一个组件,性能也不错,就选定它了。

数据压缩

文本数据压缩是很有必要的,能节省很多存储空间。数据库已经内置支持压缩,但我还是在应用层做了压缩,原因是: 数据库是有状态服务,迁移和扩容比较麻烦,而应用层是无状态的,扩容非常方便,所以尽量把负载转移到无状态服务上。

压缩算法有非常多,要在压缩率,压缩速度,解压速度之间权衡,这个网站提供了很有用的压测数据: http://quixdb.github.io/squash-benchmark/

在压缩数据上我增加了 1 字节的自定义版本号,可以方便支持不同的压缩算法。

+----------+-------------+
|  1 byte  |   N bytes   |
+----------+-------------+
|  version |   content   |
+----------+-------------+

目前对小于 1KB 的文章,不需要压缩,小于 16KB 的文章(大部分),用 lz4 压缩比较快, 大于 16KB 的文章,用 gzip 压缩率比较高,节省存储空间。

数据兼容和升级

这次存储改造后兼容上一版(1.4.2)的数据,只是增加了几个新表,无需做数据迁移,可以平滑升级。 应用层会先读新表数据,读不到再去读旧表,性能会有一点损失,但保持数据兼容是更明智的做法, 可以避免很多不必要的风险和痛苦。

另外 Django 的充血模型,不适应复杂的存储架构,后续要慢慢重构分层。

数据冗余和容灾

目前蚁阅只有一个数据库,依靠每天全量备份做容灾,后续要考虑增量备份。

自己做多副本容灾的话难度比较大,投入产出比太低,所以后续会考虑混合云方案, 即依靠云厂商抵抗自然灾害,靠自己活下去,哪怕网线被拔了也要能快速复活。

附存储方案压测结果

测试环境为 Mac Docker,磁盘 IO 比较差。结果仅供参考。
每个测试跑 3 轮,第一行为写性能,第二行为读性能。

SeaweedFS方案

1 volume, 1 concurrency
0 ------------------------------------------------------------
avg=2.7 p50=2.6 p80=2.8 p90=3.0 p95=3.6 p99=5.9 qps=373
avg=2.4 p50=2.3 p80=2.6 p90=2.9 p95=3.2 p99=4.6 qps=415
1 ------------------------------------------------------------
avg=2.7 p50=2.6 p80=2.9 p90=3.2 p95=3.7 p99=5.7 qps=368
avg=2.4 p50=2.4 p80=2.6 p90=2.8 p95=3.1 p99=4.5 qps=409
2 ------------------------------------------------------------
avg=2.6 p50=2.5 p80=2.8 p90=3.0 p95=3.3 p99=5.9 qps=383
avg=2.3 p50=2.2 p80=2.4 p90=2.6 p95=2.8 p99=3.2 qps=444
2 volume, 2 concurrency
0 ------------------------------------------------------------
avg=5.3 p50=3.4 p80=4.6 p90=6.0 p95=22.8 p99=40.7 qps=381
avg=4.3 p50=2.9 p80=3.7 p90=4.5 p95=6.0 p99=37.3 qps=470
1 ------------------------------------------------------------
avg=5.1 p50=3.4 p80=4.5 p90=5.7 p95=14.0 p99=38.1 qps=393
avg=4.4 p50=2.9 p80=3.7 p90=4.5 p95=6.3 p99=36.7 qps=459
2 ------------------------------------------------------------
avg=5.3 p50=3.5 p80=4.6 p90=5.7 p95=25.2 p99=41.4 qps=378
avg=4.2 p50=2.9 p80=3.8 p90=4.8 p95=6.4 p99=37.0 qps=475
2 volume, 20 concurrency
0 ------------------------------------------------------------
avg=55.2 p50=66.5 p80=86.4 p90=92.1 p95=97.3 p99=109.5 qps=363
avg=44.9 p50=26.5 p80=77.5 p90=84.6 p95=89.5 p99=96.3 qps=445
1 ------------------------------------------------------------
avg=57.4 p50=71.7 p80=87.9 p90=94.1 p95=98.4 p99=109.9 qps=349
avg=45.7 p50=27.6 p80=76.7 p90=84.3 p95=90.4 p99=101.4 qps=438
2 ------------------------------------------------------------
avg=60.3 p50=74.0 p80=90.9 p90=96.9 p95=101.9 p99=111.1 qps=332
avg=43.9 p50=23.5 p80=76.9 p90=83.8 p95=88.6 p99=96.0 qps=456
10 volume, 20 concurrency
0 ------------------------------------------------------------
avg=60.3 p50=75.4 p80=90.3 p90=95.2 p95=100.6 p99=107.3 qps=332
avg=53.9 p50=62.8 p80=86.2 p90=92.4 p95=97.6 p99=104.4 qps=371
1 ------------------------------------------------------------
avg=58.1 p50=71.2 p80=88.7 p90=95.2 p95=98.7 p99=105.7 qps=344
avg=47.2 p50=25.8 p80=80.1 p90=86.7 p95=91.0 p99=100.1 qps=424
2 ------------------------------------------------------------
avg=59.0 p50=68.7 p80=89.0 p90=95.2 p95=101.0 p99=107.9 qps=339
avg=49.3 p50=44.1 p80=81.9 p90=88.3 p95=94.9 p99=100.8 qps=406

PostgreSQL方案

concurrency=1
0 ------------------------------------------------------------
avg=3.6 p50=3.5 p80=3.9 p90=4.2 p95=4.6 p99=6.4 qps=275
avg=3.2 p50=3.1 p80=3.5 p90=3.8 p95=4.2 p99=5.0 qps=310
1 ------------------------------------------------------------
avg=3.9 p50=3.8 p80=4.2 p90=4.6 p95=4.9 p99=6.4 qps=254
avg=3.1 p50=2.9 p80=3.3 p90=3.7 p95=4.0 p99=7.9 qps=322
2 ------------------------------------------------------------
avg=4.2 p50=3.9 p80=4.5 p90=4.9 p95=5.4 p99=8.5 qps=240
avg=3.2 p50=3.1 p80=3.5 p90=3.8 p95=4.1 p99=4.8 qps=310
concurrency=2
0 ------------------------------------------------------------
avg=4.7 p50=4.5 p80=5.2 p90=5.8 p95=6.3 p99=8.2 qps=426
avg=3.6 p50=3.5 p80=4.0 p90=4.4 p95=4.7 p99=6.5 qps=556
1 ------------------------------------------------------------
avg=4.5 p50=4.4 p80=4.9 p90=5.4 p95=5.8 p99=7.1 qps=443
avg=3.7 p50=3.5 p80=4.0 p90=4.4 p95=4.7 p99=7.3 qps=547
2 ------------------------------------------------------------
avg=4.7 p50=4.5 p80=5.1 p90=5.6 p95=6.2 p99=7.7 qps=426
avg=3.8 p50=3.6 p80=4.2 p90=4.7 p95=5.3 p99=6.3 qps=529
concurrency=5
0 ------------------------------------------------------------
avg=10.5 p50=7.3 p80=10.1 p90=19.2 p95=38.2 p99=50.1 qps=474
avg=5.9 p50=5.4 p80=6.5 p90=7.4 p95=9.7 p99=15.8 qps=853
1 ------------------------------------------------------------
avg=10.7 p50=7.4 p80=10.1 p90=28.8 p95=38.5 p99=45.0 qps=466
avg=6.2 p50=5.6 p80=6.8 p90=7.9 p95=9.2 p99=20.7 qps=813
2 ------------------------------------------------------------
avg=10.5 p50=7.8 p80=10.7 p90=21.1 p95=33.8 p99=38.5 qps=475
avg=6.5 p50=6.3 p80=7.6 p90=8.5 p95=9.3 p99=12.3 qps=768
concurrency=10
0 ------------------------------------------------------------
avg=21.7 p50=15.7 p80=37.6 p90=47.1 p95=52.2 p99=60.7 qps=461
avg=14.7 p50=12.7 p80=18.2 p90=26.0 p95=35.7 p99=41.6 qps=680
1 ------------------------------------------------------------
avg=22.4 p50=16.0 p80=40.3 p90=52.0 p95=56.1 p99=62.7 qps=447
avg=13.3 p50=11.8 p80=16.4 p90=20.7 p95=27.1 p99=41.1 qps=754
2 ------------------------------------------------------------
avg=24.0 p50=16.9 p80=44.4 p90=53.6 p95=58.2 p99=79.3 qps=416
avg=15.0 p50=12.8 p80=17.6 p90=27.2 p95=34.0 p99=44.6 qps=668
Back

留言 - Github Issues | 主题 - The Plain