2

蚁阅海量文章存储方案

 2 years ago
source link: https://blog.guyskk.com/notes/rssant-story-storage
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

蚁阅海量文章存储方案

2020年, 6月21日

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

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

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

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

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

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

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK