2029选举项目存储架构深度解析

本文详细介绍了2029选举网站的数据存储实现,涵盖Firestore与本地JSON文件双存储方案、基于清单的存储优化策略、数据模型序列化技术选择,以及跨文档引用处理机制。

存储实现

自从我上一篇关于数据模型的文章发布以来,我对系统进行了小幅简化——基本上是在写文章时想到的改进现在已经实现了。我不会详细介绍这些变更的细节,因为它们并不重要,但这解释了为什么有些示例看起来可能缺少内容。

目前我有两种存储实现:

  • 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. 直接序列化和反序列化其他代码使用的数据模型

这三种方法我都成功使用过——例如,我的教堂音视频系统使用了最后一种方法。第一种(最显式)方法我有时用于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都支持反序列化到记录,但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、字段名等方面有很多冗余——但如果可以避免,我不希望陷入这种情况。因此,我选择将每个投影集存储在自己的文档中。

基于清单的存储

考虑到这一点,我想到了为上下文设置“清单”并将其存储在一个文档中的想法。上下文中的每个集合仍然是一个文档,但该文档的名称将基于哈希(当前实现中的SHA-256)——并且清单记录哪些文档在上下文中。当存储新上下文时,如果具有我们要写入的相同名称的文档已经存在,我们可以跳过它。(我假设不会有任何哈希冲突——这感觉合理。)例如,“添加投票”的常见操作只需要为“所有投票”写入一个新文档(诚然,这与之前的“所有投票”文档大部分相同),然后写入引用该新文档的新清单。

对于席位投影,有一个文档列表而不是单个文档。

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

  • 查看是否有新清单。如果没有,我们完成。
  • 否则,加载新清单。
  • 对于新清单中的每个文档,查看它是否与旧清单中的文档相同。如果是,数据没有更改,因此我们可以使用旧数据模型,适应更新任何交叉引用(更多内容见下文)。如果它实际上是一个新文档,我们需要正常反序列化它。

这会留下旧清单和仅由旧清单引用的旧文档形式的“垃圾”文档。这些实际上不会造成任何伤害——占用的存储量非常少——但它们确实使得在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>
    /// 返回来自不同核心上下文的等效模型(例如具有相同ID)。
    /// </summary>
    T InCoreContext(ElectionCoreContext coreContext);
}

public interface INonCoreModel<T>
{
    /// <summary>
    /// 返回引用给定核心上下文元素的新但等效模型。
    /// </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,并为每个CandidateResult调用WithCoreContext来实现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 设计