深入解析选举网站的数据存储架构:Firestore与JSON文件存储实践

本文详细介绍了为选举网站设计的数据存储系统,包括使用Firestore和本地JSON文件的两种实现。探讨了基于清单(Manifest)的存储策略、数据模型设计、跨文档引用处理,以及如何在保证一致性的同时优化读取性能和成本。

自上一篇文章讨论数据模型以来,我已对设计做了一些简化——基本上是在撰写那篇文章时想到的改进点现在已经实现了。我不会详细介绍这些更改,因为它们并不重要,只是解释一下为什么有些例子看起来可能缺少某些内容。

目前我有两种存储实现:

  • Firestore:为测试、预发布和生产环境使用单独的命名数据库。
  • 磁盘上的JSON文件:用于开发目的,以便每次本地启动站点时无需访问Firestore。(“测试"Firestore数据库使用不多——但能够在将Firestore存储实现的更改推送到预发布环境之前,轻松切换到使用它进行测试还是很不错的。)

正如我之前提到的,数据模型在内存中是不可变的。我使用一个接口(IElectionStorage)来抽象存储方面(至少是大部分),因此几乎没有任何代码(站点本身的代码中也没有)需要知道或关心正在使用哪个实现。这个接口最初相当细粒度,为 ElectionContext 的不同部分提供了单独的方法。但现在它变得非常简单:

1
2
3
4
5
public interface IElectionStorage
{
    Task StoreElectionContext(ElectionContext context, CancellationToken cancellationToken);
    Task<ElectionContext> LoadElectionContext(CancellationToken cancellationToken);
}

这篇文章将深入探讨如何实现这个接口——主要聚焦于Firestore方面,因为它更有趣。

存储类

当选择将数据模型存储为JSON或XML时,我通常最终遵循以下三种大致模式之一:

  1. 直接在代码中生成和使用JSON/XML(使用JObjectXDocument等)。
  2. 分离"存储"和"使用”:引入一组大致镜像代码其余部分使用的数据模型的并行类型;这些特定于存储的类仅作为执行序列化和反序列化的更简单方式。
  3. 直接序列化和反序列化代码其余部分使用的数据模型。

我对这三种方法都取得过成功——例如,我的教堂A/V系统使用了最后一种。第一种(最显式的)方法我有时用于XML,但Json.NET(和System.Text.Json)使得序列化和反序列化类型到/从JSON变得非常容易,我很少在那里使用它。(我知道存在XML序列化选项,但我从未发现它们像JSON序列化那样易于使用。)

中间选项——为序列化/反序列化单独设置一组类——感觉像是一个最佳平衡点。这确实涉及到重复——当我向核心数据模型之一添加新属性时,也必须将其添加到我的存储类中。但这种情况相对较少发生,并且它使事情更加灵活,并将所有存储关注点排除在核心数据模型之外。它也帮助我避免围绕什么易于存储来设计数据模型。

关于JSON库选择的说明: 在我的选举网站中,我使用的是Json.NET(又名Newtonsoft.Json)。我毫不怀疑System.Text.Json在性能方面有一些优势,但由于使用了多年,我个人仍然更习惯于Json.NET。JSON代码部分没有性能敏感的问题,所以我选择了熟悉的工具。

数据模型是不可变的这一事实也鼓励选择使用单独的存储类。虽然Json.NET和System.Text.Json都支持反序列化到记录(records),但Firestore的反序列化代码有些限制。我可以编写自定义转换器来使用构造函数(并且我已经添加了一些自定义转换器),但如果我们分离存储和使用,只让存储类可变也是可以的。

最初——实际上很长时间以来——我有分别用于JSON(必要时用[JsonProperty]装饰)和用于Firestore(用[FirestoreData][FirestoreProperty]装饰)的单独存储类。这为我提供了为文件和JSON使用不同存储表示的灵活性。但过了一段时间后,我清楚地意识到我实际上并不需要这种灵活性——所以现在只有一个类型集用于两种存储实现。如果我愿意,仍然有机会为特定数据片段使用不同的类型——有一段时间,除了邮政编码映射之外,我有一个共享的表示形式。

存储表示形式

文件存储 在磁盘上,上下文中的每个集合都有自己的JSON文件——并且有一个单独的timestamp.json文件用于整个上下文的时间戳。所以文件是ballots-2029.jsonby-elections.jsoncandidates.jsonconstituencies.jsondata-providers.jsonelectoral-commission-parties.jsonnotional-results-2019.jsonparties.jsonparty-changes.jsonpolls.jsonpostcodes.jsonprojection-sets.jsonresults-2024.jsonresults-2029.jsontimestamp.json。目前不到4MB。磁盘上的测试环境用于一些自动化测试以及本地开发,因此它被提交到源代码控制中。这对于记录数据如何变化的历史很有用。

Firestore存储——初步思考 Firestore稍微复杂一些。一个Firestore数据库由集合中的文档组成——一个文档也可以包含自己的集合。因此,Firestore文档的路径(相对于数据库根目录)类似于 collection1/document1/collection2/document2。那么,我们应该如何在Firestore中存储选举数据呢?

有四个重要的要求:

  1. 启动时必须能够合理地快速加载整个上下文。
  2. 在使用我们想要的任何内存缓存时,必须能够快速加载更改后的新上下文。
  3. 我们必须保持一致:当新上下文可用时,加载它应该加载新上下文中的所有内容。(例如,我们不希望在新上下文完成存储之前就开始加载它。)
  4. 我是个吝啬鬼:即使网站变得流行,运行起来也必须便宜。

首先,考虑一下我们文档的"块大小"。Firestore定价主要基于(至少在我们的情况下)我们执行的文档读取次数。一种选择是让每个集合中的每个对象成为自己的文档——这样每个选区、每个政党、每个候选人、每个结果都会有一个文档。这将在启动服务器时涉及读取数千个文档,最终可能相对昂贵——而且我预计这比在数量少得多的文档中读取相同数量的数据要慢。

在块大小的另一个极端,我们可以尝试将整个上下文存储在单个文档中。由于Firestore存储限制,这行不通:每个文档(不包括嵌套集合中的文档)的最大大小约为1MB。(有关文档大小计算方式的详细信息,请参阅文档。)

在Firestore中以与磁盘上大致相同的方式处理数据效果很好——将每个集合放在自己的文档中。在这里我唯一会有点担心的文档是预测集合。每个预测集包含多达650个选区(更常见的是632个,因为很少有预测集包含北爱尔兰的预测)的预测详情。目前,这没问题——但如果选举前有很多预测集,我们可能会达到1MB的文档限制。我相信我可以优化存储的数据量——在政党ID、字段名等方面有很多冗余——但如果可以避免,我宁愿不这样做。因此,我选择将每个预测集存储在它自己的文档中。

那么,我们应该如何存储上下文呢?最初,我只使用了一组静态的文档名称(通常是Firestore集合中一个以上下文中的集合命名的全值文档——例如results-2024/all-valuesresults-2029/all-values等),但这无法满足一致性要求……至少不是以一种简单的方式。Firestore支持事务,所以理论上我可以在同一时间提交新上下文的所有数据,然后也在事务中读取,以有效地在某个时间点获得一致的快照。我相信那会奏效,但使用事务会比不使用事务至少稍微麻烦一些。

另一个选择是每个上下文有一个集合——这样,只要我做了任何更改,我就会创建一个新集合,在该集合中有一套完整的文档来代表上下文。我们仍然需要想办法避免在上下文存储过程中开始读取,但至少我们不会修改任何文档,因此更容易推理。同样,我相信那会奏效——但考虑到数据的一个非常重要的特点,这感觉真的很浪费:

几乎所有的数据变化都非常缓慢。 很少出现新政党、新选区、新补选等。2019年和2024年选举的数据在这一点上不会改变,除非模式更改等。现有的民调和预测集几乎从未被修改过(尽管如果我的处理代码有错误或某些元数据有问题,这种情况也可能发生)。换句话说,“新"上下文中的绝大部分数据与"旧"上下文中的数据相同。

基于清单(Manifest)的存储 考虑这一点时,我想到了一种方法:为上下文设置一个"清单”,并将其存储在一个文档中。上下文中的每个集合仍然是一个单独的文档,但该文档的名称将基于一个哈希值(当前实现中是SHA-256)——而清单记录了上下文中包含哪些文档。当存储一个新上下文时,如果我们将要写入的具有相同名称的文档已经存在,我们可以直接跳过它。(我假设不会有任何哈希冲突——这听起来是合理的。)例如,常见的"添加民调"操作只需要为"所有民调"写入一个新文档(尽管这个文档很大程度上与之前的"所有民调"文档相同),然后写入引用这个新文档的新清单。对于席位预测,有一个文档列表,而不是单个文档。

从头加载上下文需要加载清单,然后加载它引用的所有文档。但重要的是,加载一个新上下文可以非常高效:

  1. 检查是否有新清单。如果没有,我们就完成了。
  2. 否则,加载新清单。
  3. 对于新清单中的每个文档,查看它是否是旧清单中相同的文档。如果是,数据没有改变,因此我们可以使用旧的数据模型,进行一些调整以更新任何交叉引用(下面会详细说明)。如果它实际上是一个新文档,我们需要像往常一样反序列化它。

这会留下旧清单和仅被旧清单引用的旧文档形式的"垃圾"。这些实际上并没有任何危害——占用的存储量真的非常少——但它们确实使在Firestore控制台中检查数据变得稍微困难一些。幸运的是,定期清理存储很简单:删除任何创建时间超过10分钟的清单(以防任何服务器仍在从最近创建但非最新的清单加载数据),并删除任何未被剩余清单引用的文档。这是访问存储的代码需要知道它正在与哪个实现进行交互的唯一时刻——因为清理操作只存在于Firestore。

交叉引用

如前一篇文章所述,上下文中的数据模型实际上有两层:核心非核心。核心模型首先加载,并且相互引用。非核心模型不相互引用,但可以引用核心模型。

如果清单中的任何核心集合发生了变化,我们需要创建一个新的核心上下文。如果它们都没有改变,我们可以完全保留"旧"核心上下文(即重用现有的ElectionCoreContext对象)。在这种情况下,存储中未发生改变的任何非核心模型也可以被重用——它们的所有引用仍然有效。

如果核心上下文发生了变化——那么我们有了一个新的ElectionCoreContext——那么存储中未发生改变的任何非核心模型都需要重新创建以引用新的核心上下文。(例如,如果候选人的姓名更改,那么显示2024年选举结果时应显示那个新姓名,即使选举结果本身没有改变。)

这在代码中很容易做到,并且以一种通用的方式,通过引入两个接口和一些扩展方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface ICoreModel<T>
{
    /// <summary>
    /// Returns the equivalent model (e.g. with the same ID) from a different core context.
    /// </summary>
    T InCoreContext(ElectionCoreContext coreContext);
}

public interface INonCoreModel<T>
{
    /// <summary>
    /// Returns a new but equivalent model which references elements from the given core context.
    /// </summary>
    T WithCoreContext(ElectionCoreContext coreContext);
}

public static class ContextModels
{
    [return: NotNullIfNotNull(nameof(list))]
    public static ImmutableList<T> InCoreContext<T>(
        this ImmutableList<T> list, ElectionCoreContext coreContext)
        where T : ICoreModel<T> => [.. list.Select(m => m.InCoreContext(coreContext))];

    [return: NotNullIfNotNull(nameof(list))]
    public static ImmutableList<T> WithCoreContext<T>(
        this ImmutableList<T> list, ElectionCoreContext coreContext)
        where T : INonCoreModel<T> => [.. list.Select(m => m.WithCoreContext(coreContext))];
}

我们最终会有相当多的"有些样板"的代码来实现这些接口。Result 是一个简单的例子,它通过调用其直接核心上下文依赖项的 InCoreContext 方法以及每个 CandidateResultWithCoreContext 方法来实现 WithCoreContext。所有独立数据(字符串、数字、日期、时间戳等)都可以直接复制:

1
2
3
4
public Result WithCoreContext(ElectionCoreContext coreContext) =>
    new(Constituency.InCoreContext(coreContext), Date, WinningParty.InCoreContext(coreContext),
        CandidateResults?.WithCoreContext(coreContext), SpoiltBallots, RegisteredVoters,
        IngestionTime, DeclarationTime);

当然,这有另一种选择。我们可以每次加载新清单时都从头重新创建整个 ElectionContext。我们可以保留清单引用的所有文档的缓存,这样我们就不需要再次实际访问存储,只需反序列化即可。那可能涉及更少的代码行——但要使代码正确运行可能会更麻烦。在内存和CPU方面,效率也可能较低一些,但我坦白地说并不太担心这一点。即使在选举之夜,我们也不太可能每30秒创建一次以上的新清单。

结论

所有这些将来可能还会改变,但目前我对数据存储的方式非常满意。这并没有什么特别创新的地方,但Firestore存储的特性真的很好——即使网站变得非常流行,Cloud Run启动了多个实例,我预计在整个网站的生命周期内,Firestore的费用也不会显著增加。换句话说:预期的成本如此之低,以至于任何进一步优化它的努力,在时间成本方面几乎肯定会超过它实现的节省。(诚然,对于一个我喜欢花时间的业余项目来说,时间的"成本"很难量化。)当然,我会持续关注我的GCP支出情况,以确保现实与理论相符。

我相信我几乎可以轻而易举地在Google Cloud Storage(blob存储)中实现Firestore的存储方法——尤其是考虑到本地存储的JSON序列化已经实现。我可以像在Firestore中使用单独的数据库一样,为不同的环境使用单独的存储桶。除了好奇它是否会像我想象的那么容易之外,我没有任何特别的理由这样做。(我想研究一下I/O方面的性能差异可能也会很有趣。)完全从Firestore迁移将意味着我只需要处理一种序列化格式(JSON),这反过来可能让我重新考虑为某些类型设置单独存储类的设计决策——目前这还不足以构成迁移的动力,但谁知道未来四年会带来什么变化。

在研究了如何存储模型之后,下一篇文章将讨论记录和集合。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计