实体存储
所有版本以 jsonb 保存在一张表中,查询走以类型开头的索引,引用另存一张推导的反查表,删除留下墓碑,所有操作串行执行
背景
后端要在 PostgreSQL 中保存所有类型的版本,按身份、类型和事实时间读取它们,并记住已删除的身份。业务类型的数据量相差几个数量级:键鼠事件一天几千条,有些类型一天只有几条。业务类型的字段随 Schema 版本变化,引用可以嵌在对象和数组中,读取方还要能按目标实体找到引用方。多个 API 实例可能同时处理提交和删除。
决定
- 所有类别、所有业务类型的版本保存在同一张
versions表中,内容以jsonb保存。versions是版本内容的权威位置。 entities保存每个实体的类型、当前版本和删除状态;current_observations保存每份 Observation 当前版本的事实时间范围和观测内容的类型。两张表由版本推导,供查询使用。- 按类型和事实时间的查询,使用以类型开头的索引。
- 删除实体时移除它的全部版本,只把身份写入墓碑,墓碑永久保留。
- 所有操作在多个 API 实例之间串行执行。一次提交在一个事务中完成,某个版本被拒绝时,提交它之前已保存的版本。
- 引用的权威位置是版本的内容(ENTITY-8)。
version_references由内容推导,每个非空的引用一行,记下引用方版本、位置(JSON Pointer)、目标版本和目标实体,供反查使用;previousVersionId和schemaVersionId不写入。
理由
字段随 Schema 版本变化。按字段建列,每出一个 Schema 版本就要改表,嵌在对象和数组中的引用也放不进列。以 jsonb 保存,每个版本按提交时的结构原样保存,按它引用的 Schema 版本解释。
查询的代价取决于索引读到的行,与表的总行数关系不大。以类型开头的索引让每个查询只读到目标类型的行,所以键鼠事件再多,也不会拖慢一天只有几条的类型。版本不可改写(VERSION-2),versions 几乎只追加,大表中常见的更新膨胀很少出现。
已删除的身份不能再分配(VERSION-9),实体也不能复活(HISTORY-7),所以后端要记住这些身份。墓碑只保存身份,内容不留副本,见 ADR · 删除。
个人数据的写入量不高。所有操作串行执行,检查和写入之间就不会插进别的操作,删除也不会和引用它的新版本交错。一次提交一个事务,提交次数从版本数降到请求数,导入大量数据时差距最大。
一份观测只需在当前版本变化时更新 current_observations 的一行。PostgreSQL 的范围类型按重叠匹配,与 API 的时间查询规则一致,GiST 索引直接支持这种查询。
哪些位置是引用,由 Core 和每个版本的 Schema 版本决定,同一个字段名在不同类型中可以不是引用。所以保存时按 Schema 版本找出引用,逐个写成行;查询只读 version_references 的索引,不用解析 jsonb。目标实体在写入时记下,按实体归集(ENTITY-7)就是一次索引查找。目标版本删除后,墓碑和引用方的内容都还在,这一行也保留,所以 target_version_id 不设外键。前一版本和 Schema 版本已经是 versions 的列,再写一份只会让行数翻倍。
放弃的方案:
- 每个业务类型一张、按字段建列的表。 每个 Schema 版本都要改表,嵌套的引用放不进列。
- 按业务类型分区。 可以按类型删除数据、统计空间,但后端要在运行时建表,按 VersionId 读取还要先查一张身份表。分区对 API 和 Application 透明,需要时可以再改。
- 每个版本一个事务。 导入时提交次数等于版本数。
- 在
content上建 GIN 索引,查询时匹配 VersionId。 索引不知道哪些位置是引用,会匹配到恰好等于某个 VersionId 的普通字符串;按实体归集还要先列出目标的全部版本,也算不出引用的位置。
代价
- 所有类型共用一张表的统计信息和清理。按业务字段过滤时,索引要建成带类型条件的部分索引。
- 墓碑永久占用空间。
- 写入串行执行,吞吐受单个事务的速度限制。
- 保存和删除时,
entities和current_observations要在同一个事务中同步更新。 - 每保存一个版本,要多写它的每个引用;
version_references的行数与引用数相同,导入键鼠和前台时段时约占 1 GB。 - 反查表的正确性依赖保存时对 Schema 版本的解析,解析规则改变时要从
versions重建。