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

这三种方法我都成功使用过——例如,我所在教会的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文件——并且有一个单独的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年选举的数据现在不会改变,除了模式更改等。现有的民调和投影集几乎从未被修改过(尽管如果我的处理代码有错误或某些元数据有问题,也可能发生)。换句话说,“新”上下文中的绝大多数数据与“旧”上下文中的相同。

基于清单的存储

考虑到这一点,我想到了为上下文设置一个“清单”并将其存储在一个文档中的想法。上下文中的每个集合仍然是一个单独的文档,但该文档的名称将基于哈希(当前实现中是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>
    /// 返回来自不同核心上下文的等效模型(例如,具有相同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 设计