3.4 数据架构与存储
概述与动机
数据比代码更长寿。应用程序每隔几年就会被重写,但它们所管理的数据(客户记录、财务账本、福利历史、健康记录)却会存续数十年。它往往是组织最有价值、也受最严格监管的资产。数据架构是这样一门学科:决定这些数据如何被建模、存储在何处、如何保持一致、如何演进,以及如何在规模化场景下以足够快的速度被提供服务。对大型组织而言,这些决策是根本性的。你对存储引擎和数据模型的选择,会在系统的整个生命周期内,限制业务能做什么、能以多快的速度推进,以及需要花多少钱。
由于规模、长期性和监管的原因,企业和政府场景中的风险最高。一家银行的交易存储绝不能丢失或重复计算一分钱。一个政府登记系统必须按法定期限保留记录,并向审计人员证明其完整性。一个医疗系统必须执行细粒度的访问和数据驻留规则。与此同时,这些组织要服务巨大的读写量,承受不起让每一次查询都打到单一的关系型数据库上。因此,数据架构必须在正确性、持久性与性能、规模之间取得协调,而且要在模式(schema)不断变化以满足新要求的同时做到这一点。
本章涵盖主要的存储范式及各自的适用场景、多语言持久化(polyglot persistence)这门学科、数据建模以及常被低估的模式演进与迁移问题、缓存与内容分发网络(CDN)及其臭名昭著的失效难题,以及事务、锁和并发在被推向规模化时的表现。贯穿全章的主线很简单:不存在一种通用的数据库。只有各种权衡,而好的数据架构意味着有意识地、逐个工作负载地做出这些权衡选择。
关键原则
- 让数据建模去适配访问模式,而不是反过来。 围绕数据将如何被读写来设计存储,而不是围绕一个抽象的”正确”模型来设计。
- 不存在一统天下的数据库。 不同的工作负载需要不同的引擎;在规模化场景下,多语言持久化是常态。
- 对于系统记录,正确性优先。 对权威数据而言,持久性和一致性是不可谈判的;应围绕它们来优化性能,而不是绕过它们来优化性能。
- 模式会变化,所以要为此做好规划。 迁移是一项一等的、持续的工程活动,而不是一次性的事情。
- 在服务边界之内拥有你自己的数据。 每个限界上下文(bounded context,一个拥有自己明确边界的自包含领域模型)都拥有自己的数据;共享数据库会把团队耦合在一起,破坏自主性。
- 缓存是伪装成性能收益的正确性问题。 每一个缓存都会引入陈旧和失效风险;要有意识地对待它。
- 反规范化是一种权衡取舍,而不是一种罪过。 为了读取性能而复制数据是正当的,只要你能承担其一致性后果。
- 一致性与规模之间此消彼长。 事务保证越强,就越难分布式化;只购买工作负载真正需要的那部分保证。
建议
依据工作负载选择存储范式
把每种工作负载与适合它的模型匹配起来。关系型数据库提供强一致性、连接(join)能力和成熟的事务机制;它们是系统记录以及任何具有复杂完整性规则的场景的默认选择。文档型存储适合层级化的、模式灵活的、以整体方式读取的数据(一整份订单、一整份档案)。键值型存储为简单查找(会话、功能开关、缓存)提供极致速度。图数据库在关系本身就是查询对象的场景中表现出色(欺诈团伙、组织架构图、权限授予、供应链)。列式存储为在数十亿行数据上仅扫描少数几列的分析查询提供动力(数据仓库、报表)。时间序列数据库为追加密集型、带时间戳的数据(指标、遥测数据、物联网(IoT)传感器、行情数据)做了优化。不要强迫一种引擎去承担所有工作。把关系型数据库当作队列使用,或者把文档存储当作账本使用,都会招致麻烦。
有意识地采用多语言持久化
大型系统合理地使用多种存储:一个关系型系统记录、一个搜索索引、一个缓存、一个分析仓库,也许还有一个图数据库或时间序列引擎。这就是多语言持久化,当工作负载真正存在差异时,这是正确的模式。其代价体现在运维上,因为你现在有更多引擎需要运行、加固安全、备份和配备人手。要通过让每个存储由一个服务拥有、将运维工具标准化、并把技术种类限制在真正值得引入的那些,来管理这一成本。要警惕为每一个次要需求都引入一个新数据库。每一个都是永久性的运维承诺。
建模数据,把模式演进当作持续性工作对待
为系统记录提前投入数据建模。先做规范化来保护完整性,再针对被证实的读取热点有选择地进行反规范化。无论采用哪种模型,模式都会永远演进下去,因此要让迁移变得安全且常规化。使用带版本号的、自动化的、只向前推进的迁移脚本,将其纳入源代码控制,并通过部署流水线来应用。对于大表上的零停机变更,使用扩展-收缩(expand-contract,即并行变更)模式:添加新列或新表,回填并双写,迁移读取方,然后再移除旧的结构。绝不要用单次破坏性的 alter 操作。使模式变更跨部署保持向后兼容,这样新旧代码就能同时运行。在事件溯源或基于消息的系统中,要显式地为你的事件和消息模式设置版本号,并支持对旧事件进行向上转换(upcasting,即在读取时将其转换为当前模式)。
睁大眼睛设计缓存与失效机制
缓存和 CDN 是杠杆效应最高的性能工具。CDN 从靠近用户的边缘节点提供静态和可缓存的内容,应用缓存则让数据库免于重复读取的压力。但难点在于失效:知道缓存数据何时已经过时。要按具体场景选择策略。在轻微陈旧可以接受、且这是最简单方案的地方使用基于时间的过期(TTL)。在新鲜度至关重要的地方使用显式失效或写穿(write-through)。在应用程序自行管理数据填充的地方使用旁路缓存(cache-aside)。要有意识地设置 TTL,用加锁或请求合并来防范缓存踩踏(cache stampede,即许多客户端同时重建同一个已过期条目),并防止冷缓存上出现惊群效应(thundering herd)。绝不要在没有一条明确的、经过测试的失效路径的情况下,缓存那些陈旧可能导致正确性或合规性问题的数据(权限、余额、同意状态)。要把缓存键、TTL 和失效机制当作经过设计的产物,而不是附带的配置项。
为规模化管理事务、锁和并发
理解隔离级别,并为每个事务选择仍能保证正确性的最弱隔离级别,因为更高的隔离级别会牺牲并发性。对低争用、读密集型的工作负载,优先使用乐观并发控制(写入时进行版本检查),只有在真正遇到高强度争用时才使用悲观锁,并保持锁的持有时间短、加锁顺序一致,以避免死锁。随着规模扩大,单一的可写数据库会成为瓶颈。为读取扩展引入只读副本(接受复制延迟),并按一个能均匀分摊负载、同时把相关数据放在一起以避免跨分片事务的键来进行分片/分区。要记住,分片会以牺牲简单的跨分片连接和跨分片的 ACID(原子性、一致性、隔离性、持久性)事务为代价,这往往正是 Saga 模式和反规范化出现的原因。只在工作负载真正需要时才引入这些技术。过早分片会永久性地增加复杂度。
权衡:优点与缺点
| 存储类型 | 最适合 | 优势 | 劣势 |
|---|---|---|---|
| 关系型 | 系统记录、复杂完整性 | ACID、连接能力、成熟的工具生态 | 水平扩展写入更困难 |
| 文档型 | 聚合读取、灵活模式 | 整体对象的快速读写、灵活 | 跨文档连接/事务能力弱 |
| 键值型 | 会话、缓存、简单查找 | 极致的速度和规模 | 除了按键查找外无法查询 |
| 图数据库 | 关系密集型查询 | 遍历速度快、表达力强 | 运维技能小众、扩展受限 |
| 列式 | 分析、报表 | 聚合扫描快、压缩率高 | 不适合行级事务写入 |
| 时间序列 | 指标、遥测数据、物联网 | 高效的追加和时间查询 | 用途较窄 |
主要的权衡在于一致性与丰富查询能力,相对于水平可扩展性与速度。关系型系统提供最强的保证和最灵活的查询能力,但在多机器间扩展写入是最困难的。NoSQL(非关系型)系列通过放松连接、事务或模式约束来换取规模和速度。缓存用新鲜度换取延迟。分片用跨分区事务能力换取写入吞吐量。这些都不是普遍正确的选择。真正的艺术在于,把每种工作负载放到其正确性和性能需求真正要求的那一点上。
与团队讨论的问题
对于每一个关键数据集,每个人都能说出唯一的那个系统记录吗,还是缓存和投影正被悄悄当作真相来对待? 数据比代码更长寿,而最具破坏性的数据事故往往源于漂移:某个缓存、搜索索引或读取投影被误认为是权威数据,并悄悄地与真正的数据源产生偏差。在大型团队中,这种情况发生在所有权模糊、多个服务写入重叠副本的时候,以至于在事故中没有人能说清哪个值才是正确的。带上你重要数据的一张地图,为每一项数据标明唯一的权威存储,以及必须能够从它重建出来的衍生副本。在金融和政府领域,能够证明哪条记录是法律上的数据源、并能重建其余部分,往往是监管要求,而不是一种便利。任何你无法从系统记录重建出来的东西,其本身就是一个系统记录,无论你是否有意让它成为这样。
你所拥有的每一个数据库引擎,其实际运行、加固安全和备份成本是多少,它们是否每一个都仍然值得保留? 当工作负载真正存在差异时,多语言持久化是正确的,但每一个引擎都是永久性的运维承诺:打补丁、备份、监控、安全评审,以及在凌晨三点也了解它的员工。大型组织很容易漂移成一个为单个功能而各自引入的存储动物园,而那些边际存储会永远增加成本,却服务着一个你已经运行的存储本可以承担的工作负载。列出每一个引擎、证明它存在合理性的工作负载,以及谁在为它值班,然后标记出那些原本可由某个主存储满足其需求、却被单独引入的引擎。引入新数据库应当有很高的门槛,因为日后移除一个就意味着又一次迁移。在你保留的存储之间将运维工具标准化,是在不强迫一种引擎承担所有工作的前提下控制成本的方法。
用户在自己刚写入之后,从某个副本读取到陈旧值的情况可能发生在哪里,这是否打破了你对他们的承诺? 只读副本能扩展读取能力,但会滞后于主库,因此一个更新了个人资料并立即刷新页面的用户,可能会看到旧值,这读起来像一个缺陷,而对于余额或同意标记而言,则是一次合规失败。要按具体流程决定”读己所写”是否重要,并将这些读取路由到主库,或使用会话一致性机制。带上由副本提供服务的流程清单,标出哪些是用户在写入之后立即会用到的。对于余额、权限和同意状态,要把陈旧读取当作正确性问题,而不是表面问题。关键在于为每种工作负载真正需要的一致性买单,并让你所接受的陈旧程度是明确选择的,而不是意外发生的。
你今天能否在不停机的情况下更改你最大、最繁忙的那张表的模式,又有谁真正演练过扩展-收缩的各个步骤? 模式会永远演进下去,而最伤人的失败是一次锁住巨大表、冻结服务、且无法干净回滚的大爆炸式 alter 操作。在大型团队中,这一风险会被放大,因为多个服务读取同一种结构,因此一次破坏性变更需要新旧代码在一次分阶段部署中并行运行。这里相互竞争的诉求是速度:写一次 alter 操作很快,而扩展-收缩(添加新结构、回填、双写、迁移读取方、丢弃旧结构)步骤更多、更耗耐心。带上你最大的那张表、一个关于朴素 alter 操作会锁住它多久的诚实估计,以及一次有人真正端到端演练过(而不是仅停留在理论层面)的具体迁移。在持续运行、承担法定可用性目标的企业和政府系统中,一次迁移导致的停机就是一次违约,因此扩展-收缩的纪律,是获准更改模式的代价。
哪些被缓存或被复制的值,一旦陈旧就会导致合规或安全问题而非表面问题,其中每一条失效路径是否都经过测试? 缓存是一个披着性能外衣的正确性问题:危险不在于缓慢,而在于权限、余额、同意标记或访问决策在变更之后仍被提供服务。对大型组织而言,这一隐患是分散的,因为缓存和边缘层会在各团队之间不断累积,没有任何一个人能列出哪些数据被缓存在哪里、何时清除。这里的张力是真实的:激进的缓存和长 TTL 能换来延迟收益并保护数据库,而严格的新鲜度则会在这两方面都付出代价。带上一份缓存和 CDN 提供数据的清单,标出哪些条目带有合规或安全后果,并提供证据表明这些条目的失效路径已在实践中被验证过,而不仅仅是被配置过。在受监管和公共场景中,一个陈旧的同意或资格判定值是一次可被审计的失败,因此这些条目要么需要一条明确、经过测试的失效路径,要么根本不应被缓存。
你为每一个权威存储制定的保留、归档和数据驻留策略是什么,你能向审计人员证明它吗? 数据比代码更长寿,往往也比编写它的团队更长寿,因此无限增长和含糊的驻留规则会悄悄变成一个没有人负责的问题,直到某张表变得无法管理,或某条记录落在了错误的司法管辖区。大型组织横跨众多存储和地区,相互竞争的考量包括成本(热存储很昂贵,因此需要归档和分层)、性能(臃肿的表会拖慢一切)和法律义务(可能相互冲突的法定保留下限和驻留上限)。针对每一个权威数据集,带上其保留期限、数据实际存放的位置、归档和删除机制,以及对此负责的人的姓名。对企业、尤其是政府系统而言,保留和驻留通常是带有审计和主权要求的法律强制规定,因此能够证明每条记录存放在哪里、保留多久、何时销毁,是一张运营许可证,而不是一件锦上添花的事。
行业视角
创业公司。 运行一个数据库,抵制存储动物园的诱惑。一个单一的托管关系型存储能给你事务能力、一个需要备份的东西,以及一个可以推理一致性的地方,而这正是一个三人团队能够承受在脑中记住的量。只有在某个具体的慢查询或真实的读取量迫使你这样做时,才添加缓存、只读副本或搜索索引,这样复杂度的引入才有一个真正值得付费的理由。从第一天起就让迁移带上版本号,因为在一个已上线的产品上事后补上迁移纪律,比从一开始就具备它要困难得多。
小型企业。 你没有数据库专家,也没有时间去运维多个引擎,因此偏好托管存储,让你的平台提供商来处理备份、打补丁和复制。把存储选型当作一个采购决策:选择你的工具已经集成的、无趣但稳妥可靠的引擎,而不是基准测试中最快的那一个。设定一个你真正能够核实的简单保留和备份策略,绝不要在没有明确清除方式的情况下缓存任何与金钱或权限相关的数据,因为一个陈旧的价格或权限会让你失去一位客户。
企业。 核心挑战是跨众多团队的多语言持久化:一个关系型系统记录加上搜索、缓存、仓库,也许还有图数据库或时间序列引擎,每一个都由一个服务而非多个团队共享拥有。在你保留的存储之间将运维工具、备份和监控标准化,为引入新引擎设置高门槛,并把扩展-收缩迁移和显式的缓存失效作为默认做法。把整个存储资产当作一个拥有清晰数据所有权的组合来管理,这样就不会有引擎在证明其存在合理性的工作负载消失后仍继续存活,也不会有团队通过共享数据库而被耦合在一起。
政府。 数据驻留、法定保留和可证明的完整性塑造着每一个选择。在主权辖区内配置每一个存储,将 CDN 配置为只缓存非个人数据,并为必须能向监管机构重建的记录保留一份不可篡改的审计历史。由新立法强制要求的迁移,必须通过流水线以向后兼容的方式应用,使服务在立法截止日期内保持可用,而系统记录必须是可识别的,这样你才能证明哪个值是法律上的数据源,并能从它重建每一份衍生副本。
示例
创业公司。 一家种子期创业公司把一切都运行在单一的托管 PostgreSQL 实例上,并抵制在真正需要之前就添加独立搜索引擎、缓存和仓库的冲动。一个数据库意味着只有一样东西需要备份、只有一个地方需要推理一致性,事务也能直接可靠地工作,而当整个团队只有三名工程师时,这一点至关重要。他们只有在某个具体的慢查询和真实的读取量证明其合理性时,才添加 Redis 缓存和只读副本,这样复杂度的到来是有付费理由的,而不是提前引入的。
企业。 一家零售银行把其权威账本保存在一个强一致性的关系型数据库中:每一笔记账都是一个正规的 ACID 事务,并按账户范围分片以扩展写入能力。围绕它的是一个多语言的存储资产:一个用于客户查找的搜索索引、一个用于移动应用账户摘要的 Redis 缓存(写穿式,短 TTL)、一个用于监管和分析报表的列式仓库,以及一个用于交易网络欺诈检测的图数据库。对账本的模式变更使用带双写的扩展-收缩模式,这样这个全天候运行的系统就永远不需要为迁移而停机。
政府。 一个国家级车辆登记系统在一个具有法定保留期和完整审计历史的关系型系统记录中存储权威记录。面向公众的”查询车辆”查找由一个只读副本和一个带短 TTL 的边缘缓存提供服务,因为略微陈旧的公开数据是可以接受的,而读取量远远超过写入量。数据驻留法律要求所有记录都保留在国内,因此每一个存储都在主权辖区内配置,CDN 也被配置为只缓存非个人数据。为满足交通政策要求而添加新字段的迁移,通过流水线以向后兼容的方式应用,使服务在立法截止日期期间保持可用。
商业论证:动机、投资回报率与总拥有成本
数据架构决策在软件领域拥有最长、最大的成本尾巴之一,因为一旦系统和集成依赖上数据及其模式,它们就是最难改变的东西。采用良好实践(有意识的存储选型、有纪律的迁移、经过设计的缓存以及适当的分片)的成本,主要是资深工程师的时间以及一些额外的运维工具。而不采用良好实践的代价,则表现为一个被过载的单一数据库拖慢整个业务、在过晚发现存储选错时进行的紧急重新平台化、因迁移失败而延长的服务中断,以及最具破坏性的()因缓存失效处理不当或事务丢失而导致的数据损坏或合规违规。
要用可扩展性余量、事故风险和监管暴露程度来向领导层论证这一点。正确的存储选择,正是能让业务在不重写系统的情况下增长读写量的原因。有纪律的迁移,正是能让模式跟上新要求的步伐、而不需要停机的原因。正确的缓存,正是能在不产生悄无声息的陈旧缺陷的前提下提供快速用户体验的原因。要量化系统整个生命周期内的总拥有成本。一个精心选择的数据架构,能避免为绕过一个糟糕架构而不断产生的重复成本,而一次被防止的数据损坏事故,其价值通常远超把架构做好的全部成本。在受监管行业中,证明数据完整性和数据驻留合规的能力不是一个成本中心,而是一张运营许可证。
反模式与陷阱
- 跨服务共享数据库。 多个服务读写同一个模式,将团队耦合在一起,让每一次变更都成为一场协调危机。
- 一个数据库承担一切。 把分析、队列、搜索和事务全部强加到单一的关系型引擎上,直到它崩溃。
- 大爆炸式迁移。 需要停机、且无法安全回滚的单次破坏性模式变更。
- 没有失效策略的缓存。 陈旧数据被无限期提供服务,或因为没有人负责缓存何时清除而产生正确性缺陷。
- 过早分片。 在负载真正需要之前就分布数据,永久性地失去连接和事务能力,却没有任何收益。
- 忽视复制延迟。 从滞后的副本中读取自己刚写入的数据,得到陈旧数据,打破用户的预期。
- 无限制的数据增长。 没有归档或保留策略,导致表不断增长,直到性能和成本变得无法承受。
- 把衍生数据当作真相存储。 把一个缓存、索引或投影当作系统记录,之后才发现它已经产生了偏差。
成熟度模型
- 第 1 级,启动(Initiate): 一个数据库被用于所有用途。模式变更是手动且临时进行的,没有迁移纪律。缓存是偶然出现的,失效机制是事后才想到的。性能问题靠被动地购买更大的机器来解决,没有人能可靠地说出某个数据集的系统记录是哪一个。
- 第 2 级,发展(Develop): 一些存储选择已经是有意识做出的,缓存或仓库也已出现,但实践因团队而异。迁移带有版本号,但有时仍需要停机,扩展-收缩由恰好懂得这一方法的人来使用。多个服务仍在共享一个数据库,缓存策略在不同团队间各不相同。
- 第 3 级,标准化(Standardize): 多语言持久化与工作负载相匹配,每个存储由一个服务拥有,绝不共享。自动化的、向后兼容的、零停机的扩展-收缩迁移是全组织范围内记录在案、被强制执行的标准。缓存策略、TTL 和失效路径是明确的设计产物,每个数据集唯一的系统记录都有文档记录,衍生副本可从它重建。
- 第 4 级,管理(Manage): 整个数据资产被度量并对照基线加以控制。你会跟踪迁移耗时和回滚率、相对于”读己所写”要求的复制延迟、缓存命中率和陈旧事故、各存储的运维成本,以及目标百分位数下的查询延迟,并依据这些数字采取行动。保留和驻留情况会依据法定要求接受审计,衍生数据的正确性会被持续验证,每一个引擎都必须能证明其成本相对于它所服务的工作负载是合理的。
- 第 5 级,编排(Orchestrate): 数据架构被持续改进,并与全组织范围的容量、成本和风险规划相融合。随着访问模式和成本的变化,分片、缓存和一致性方面的选择会按工作负载重新平衡,不再值得保留的存储会通过有计划的迁移退役。模式演进、归档和数据驻留完全自动化且具有适应性,使整个存储资产能够随新要求和负载的变化而自我重塑,而无需紧急重新平台化。
讨论思路
- 你当前哪些存储正在承担并非为其设计的工作,正确的引擎应该是什么?
- 你今天能否在你最大的表上执行零停机的模式变更?如果不能,为什么?
- 你的系统在哪些地方缓存了这样的数据:一旦陈旧就可能导致合规或正确性失败?
- 哪些服务共享一个数据库,让每个服务都拥有自己的数据库需要付出什么代价?
- 单一可写数据库在哪里成为了你的扩展天花板,只读复制还是分片是正确的下一步?
- 你的保留和归档策略是什么,谁对此负责?
关键要点
- 数据比代码更长寿;存储和建模决策会在系统的整个生命周期内限制业务。
- 让每种工作负载与适合其访问模式的存储范式匹配;在规模化场景下应预期出现多语言持久化。
- 让每个服务拥有自己数据的所有权;绝不通过共享数据库将团队耦合在一起。
- 把模式演进当作持续性工作对待,使用向后兼容的、零停机的扩展-收缩迁移。
- 缓存是一个正确性问题:有意识地设计 TTL、失效机制和防踩踏保护,绝不在没有经过测试的失效路径的情况下缓存对合规至关重要的数据。
- 只购买每种工作负载真正需要的一致性和事务保证;分片和复制是用跨分区事务能力换取规模。
参考文献与延伸阅读
- Martin Kleppmann, Designing Data-Intensive Applications
- Pramod Sadalage and Martin Fowler, NoSQL Distilled
- Pramod Sadalage and Scott Ambler, Refactoring Databases: Evolutionary Database Design
- C. J. Date, An Introduction to Database Systems
- Joe Celko, SQL for Smarties
- Vlad Mihalcea, High-Performance Java Persistence(事务、隔离性、并发)
- Eric Evans, Domain-Driven Design(限界上下文与数据所有权)
- Werner Vogels, “Eventually Consistent”