Skip to content

05 · 数据与状态:系统真正的难点

一句话点题:逻辑好改,数据难改。 代码写错了,改一行重新部署就行;但当几亿条数据已经按某种结构躺在库里、用户的状态正实时变动时,你想改它——那是要命的。架构的真正难点,从来不在逻辑,而在数据与状态。


为什么说「状态是万恶之源,也是价值之源」

先建立一个最重要的直觉:把系统里的东西分成两种——

  • 无状态(Stateless):它不记得任何事。你给它输入,它算出输出,算完就忘。比如一个「把华氏温度换算成摄氏」的函数,或者一个只负责转发请求的网关。
  • 有状态(Stateful):它记得事情,而且这些记忆会随时间变化。比如「用户的账户余额」「购物车里有什么」「这个房间里现在有谁在线」。

这两者在「能不能轻松扩容」上,是天壤之别:

   无状态组件:像便利店收银员,谁来都一样,不够就再加几个
   ┌────┐ ┌────┐ ┌────┐ ┌────┐
   │ 实例│ │ 实例│ │ 实例│ │ 实例│   ← 想扩容?直接复制一份,流量随便分
   └────┘ └────┘ └────┘ └────┘       它们之间不需要互相知道任何事

   有状态组件:像图书馆里那本独一无二的孤本
   ┌──────────────────┐
   │   它"记得"东西      │   ← 想复制一份?那两份的"记忆"怎么保持一致?
   │  (余额/库存/会话)  │      复制就有同步问题,这就是一切麻烦的根源
   └──────────────────┘

无状态组件好扩,有状态组件难扩。 这是分布式系统的第一性原理之一。

所以你会看到一个反复出现的架构动作:想方设法把「状态」从「计算」里挤出去,集中关进少数几个专门管状态的地方(数据库、缓存),让其余组件尽量保持无状态、可以随便复制。 这样系统的大部分都能轻松水平扩展,只把「难搞的状态」收拢到可控的几处去精心伺候。

但状态也绝不是只有坏处。说到底:

状态是万恶之源——所有的一致性难题、扩容难题、故障恢复难题,根子都在它。状态也是价值之源——用户的数据、订单、关系、记忆,正是产品的全部价值所在。

架构师的工作,不是消灭状态(消灭不了),而是把状态管理得明明白白:哪些数据放哪、要多强的一致、怎么扩、怎么在故障时不丢不乱。这一章就讲这些。


一、数据放哪:不同的「访问形态」,对应不同的存储

新人最常见的误区是「我会用某个数据库,就拿它装一切」。这就像不管装什么都用同一种盒子——装液体、装螺丝、装衣服全用一个,必然别扭。

正确的思路是:先看这份数据「长什么样、怎么被访问」,再选最适合它的存储。 这叫「为访问形态选存储」。下面是架构师工具箱里最常见的几类存储,各用一两句话讲清「什么样的数据 / 访问适合它」:

存储类型最适合什么样的数据 / 访问一句话直觉
关系型(Relational)结构清晰、关系复杂、需要事务和强一致的核心数据(用户、订单、账户、库存)「钱、账、关系」的默认家;要严谨就找它
文档型(Document)结构灵活、自包含、按 ID 整取整存的数据(一篇文章、一份配置、一条会话记录)一坨「半结构化的 JSON」,字段常变就用它
键值(KV)极简的「按 key 取 value」,要求极快(会话、计数、缓存、特征)一本超大字典,要的就是闪电般的读写
列存(Columnar)海量数据上的分析聚合(报表、统计「过去一年各地区销量」)按列存,适合「扫一大片、算个总和/平均」
图(Graph)重点在关系网络本身的数据(社交关系、推荐、风控关联、知识图谱)「谁认识谁、谁连着谁」,查关系路径的专家
向量(Vector)要按语义相似度检索的数据(文本/图片的 embedding,用于 RAG、推荐)「找跟这个意思最像的」,普通数据库做不到
对象存储(Object)大块、不可变、按 ID 整取的文件(图片、视频、模型权重、备份)海量大文件的仓库,便宜、能装、不改
搜索引擎(Search)需要全文检索 / 复杂条件过滤排序的数据(商品搜索、日志检索)「输入关键词,模糊地找出相关的一堆」
时序(Time-Series)带时间戳、只追加、按时间聚合的数据(监控指标、传感器、计费流水)一条不断增长的时间轴,擅长「按时间段算趋势」

注意:一个稍微复杂的系统,几乎一定同时用好几种存储。 这不是「不够纯粹」,恰恰是成熟的标志——这叫「多语言持久化(polyglot persistence)」,即「让每种数据待在最适合它的家里」。

回想 AI 对话产品模板 的第 7 节:用户/计费用关系型、会话历史用文档型、知识检索用向量库、模型权重用对象存储、用量流水用时序——同一个产品里,五种存储各司其职。这正是「为访问形态选存储」的活教材。

怎么判断该用哪个? 别背表,问自己三个问题:

  1. 这份数据的「读写形态」是什么? 是高频小读写(KV),还是整存整取(文档/对象),还是大范围聚合(列存),还是按关系/语义/关键词找(图/向量/搜索)?
  2. 它需要多强的一致性? 一分钱都不能错(关系型,强一致),还是差一点点没关系(很多 KV、搜索场景能接受延迟)?
  3. 它会长到多大?访问会多频繁? 这决定了你要不要从一开始就考虑后面讲的复制/分片。

二、一致性谱系:从「分毫不差」到「迟早一致」

「一致性」是数据世界里最绕、也最关键的概念。先把它从「非黑即白」拉成一条谱系:

   强一致 ◀─────────────────────────────────────────▶ 最终一致
   (写完立刻,任何人读到的都是最新值)        (写完之后,过一会儿大家才看到最新值)

   "扣完款,余额立刻全网都对"            "你点了赞,别人可能几秒后才看到 +1"
   严谨,但代价高、难扩、易拖慢            宽松,好扩、高可用,但有"短暂的不一致窗口"
  • 强一致(Strong Consistency):任何一次写入,之后所有的读都能立刻读到这个最新值。代价是:为了让所有副本「步调一致」,要么牺牲速度(等大家都确认),要么牺牲可用性(协调不上就拒绝服务)。
  • 最终一致(Eventual Consistency):写入之后,各个副本会在一段时间内逐步同步,最终趋于一致;但在那个「一会儿」的窗口里,不同地方读到的值可能不一样。换来的是高可用和易扩展。

用人话讲 CAP

绕不开的是 CAP 定理。它常被讲得很玄,其实核心就一句大白话:

当网络出故障、把你的系统切成了互相联系不上的两半时(分区,P),你只能在两件事里二选一:要么继续提供服务但可能给出不一致的数据(选可用性 A),要么为了不出错而干脆拒绝服务(选一致性 C)。

关键在于:网络分区(P)不是你能选的——只要是跨机器的分布式系统,网络迟早会抽风,分区一定会发生。 所以 CAP 真正的含义不是「三选二」,而是:

        网络正常时:C 和 A 你都能要,岁月静好

              网络分区发生(迟早的事)

              ┌──────────┴──────────┐
              ▼                     ▼
       选 CP(一致性优先)       选 AP(可用性优先)
   "宁可不服务,也不给错数据"   "宁可给旧数据,也得继续服务"
    适合:钱、账、库存           适合:点赞数、浏览数、动态流

哪些数据要强一致,哪些可以最终一致?

这是个业务判断,不是技术判断。判断标准只有一个:这份数据「短暂地不一致」,会造成多大的真实后果?

  • 必须强一致的(后果严重,错了要赔钱/出事):
    • 💰 账户余额、支付:多扣一分、双花一笔,都是事故。
    • 📦 库存扣减:超卖了,就是卖了不存在的货,要赔偿和道歉(这是电商的命门)。
    • 🔐 唯一性约束:同一个用户名不能注册两次。
  • 可以最终一致的(短暂不一致没人真的在意):
    • 👍 点赞数、浏览量:你看到 1024,实际 1025,有谁会因此受损?过几秒对上就行。
    • 📰 社交动态流:你发的帖,粉丝晚几秒刷到,完全可以接受。
    • 🔔 通知、已读状态:迟到一会儿无伤大雅。

架构智慧:强一致很贵,别到处滥用。 把宝贵的强一致「配额」花在真正会出事的地方(钱、库存),其余尽量用最终一致来换取可用性和扩展性。一个把「点赞数」也做成全网强一致的系统,是在为根本不需要的严谨,支付高昂的性能和可用性代价。


三、事务与 ACID,以及 BASE 的思路

当几件事必须「要么全成,要么全不成」时,你需要事务(Transaction)。最经典的例子:转账——「A 减 100」和「B 加 100」必须同时成功或同时失败,绝不能只成一半(钱凭空蒸发或凭空多出)。

传统(尤其是单机关系型数据库)的事务,追求 ACID 四个性质:

  • A 原子性(Atomicity):一组操作是一个不可分割的整体,要么全做完,要么当没发生过(出错就回滚)。
  • C 一致性(Consistency):事务前后,数据都满足既定规则(余额不会变成负数等)。
  • I 隔离性(Isolation):多个事务并发执行时,互不干扰,像排队一个个来一样。
  • D 持久性(Durability):一旦提交成功,数据就永久落地,断电也不丢。

ACID 是「严谨派」,它给你强一致和强保证。但在分布式、跨多个服务/数据库的世界里,维持 ACID 极其昂贵甚至做不到(还记得 04 章 里微服务最大的痛吗?跨服务事务几乎不可能)。

于是另一套思路应运而生——BASE,它是「务实派」:

  • BA 基本可用(Basically Available):系统整体始终可用,哪怕局部降级。
  • S 软状态(Soft State):允许数据存在「中间状态」,不要求时刻都严格一致。
  • E 最终一致(Eventual Consistency):经过一段时间,数据终将达到一致。
   ACID(严谨派)              BASE(务实派)
   "每一步都必须绝对正确"      "允许暂时不完美,但保证最终对上"
   强一致、强保证             高可用、高扩展
   代价:难扩、可能拖慢/拒服   代价:要容忍"中间态",逻辑更难写
   适合:钱、订单核心          适合:大规模、可容忍延迟的场景

这不是「谁更高级」,而是「在 CAP 面前的不同站队」:ACID 偏 CP,BASE 偏 AP。 成熟系统往往混用:核心交易走 ACID 强一致,周边的统计、通知、流式数据走 BASE 最终一致。又一次印证了——没有最好的,只有最合适的。


四、扩展数据的三大手段:复制、分片、缓存

当数据量和访问量涨上来,单台数据库扛不住了,你手里有三张牌。关键是:每张牌都在解决不同的问题,也都有不同的代价。

手段 1:复制(Replication)—— 主要为了「扩读」

做几份一样的数据副本。最常见的是「主从(Primary-Replica)」:一个主库负责写,多个从库负责读,主库的变更不断同步给从库。

                  写  ┌────────┐
   写请求 ─────────▶ │ 主库     │
                     └───┬────┘
                  同步↙   │   ↘同步
              ┌────────┐ ┌────────┐ ┌────────┐
   读请求 ───▶│ 从库 1  │ │ 从库 2  │ │ 从库 3  │ ◀─── 读请求分摊到多个从库
              └────────┘ └────────┘ └────────┘
  • 解决:读多写少的压力(绝大多数系统都是读远多于写)。读请求分散到一堆从库上,读能力随从库数量水平扩展。还顺带提供了冗余(主库挂了,从库可以顶上,提升可用性)。
  • 代价:① 主从同步有延迟,从库的数据可能比主库旧一点点(又是最终一致!你刚写完就去读从库,可能读到旧值);② 能力没有被扩展——所有写还是压在那一个主库上;③ 主库依然是写的单点,故障切换有复杂度。

手段 2:分片 / 切分(Sharding)—— 主要为了「扩写」

也扛不住、或者数据大到一台机器装不下时,就要分片:按某个规则(如用户 ID),把数据水平切成很多份,分散到多台机器上,每台只存一部分、只处理一部分的读写。

   按"用户ID"分片:
   用户 0~999     ──▶ ┌─────────┐  分片 A (独立机器,独立读写)
   用户 1000~1999 ──▶ ┌─────────┐  分片 B
   用户 2000~2999 ──▶ ┌─────────┐  分片 C
   ...                            每个分片只管自己那一摊,写压力被瓜分
  • 解决:写能力和存储容量的水平扩展——这是复制解决不了的。十台分片,理论上能扛十倍的写。
  • 代价(分片是「重武器」,代价很大):
    • 跨分片操作变得极难:想「查所有用户里消费 Top 10」?数据散在各分片,要么挨个查再汇总,要么根本做不了。跨分片的事务和 join 基本告别。
    • 分片键选错是灾难:选了个分布不均的键,会导致「热点分片」——某一台被打爆,其余的闲着(比如按地区分片,但 80% 用户都在一个地区)。
    • 再分片(扩容)很痛:数据已经按旧规则铺好了,想加机器、改规则,要搬迁海量数据,工程上非常棘手。
  • 所以:分片要尽量晚做、想清楚再做,尤其是分片键的选择,几乎是「一旦定下就很难反悔」的决策。

手段 3:缓存(Cache)—— 为了「扩读 + 降延迟」

把热点数据的副本,放到离用户更近、读取更快的地方(通常是内存级 KV),让大量重复的读请求不必每次都去打数据库

                  ┌─────────┐  命中(快!)
   读请求 ───────▶│  缓存     │────────────────▶ 直接返回
                  │ (内存级)  │
                  └────┬────┘
                       │ 未命中(miss)

                  ┌─────────┐
                  │ 数据库   │  ─── 回填到缓存,下次就命中了
                  └─────────┘
  • 解决:降低读延迟 + 减轻数据库读压力。这是性价比极高的一招,也几乎是「读扛不住」时的第一反应。
  • 代价:① 一致性问题——缓存里的是副本,数据库改了,缓存怎么及时更新?这是下面要专门讲的大难题;② 引入了新的组件要维护;③ 缓存「冷启动」(刚上线、缓存空)时,所有请求瞬间全打到数据库上。

一句话区分三张牌:复制扩「读」、分片扩「写」、缓存「降延迟+扩读」。 它们常常一起用,但解决的是不同维度的问题——别指望靠加从库来解决写瓶颈,那得靠分片。


五、缓存的三大经典难题

缓存好处巨大,但它是「用一致性换速度」的典型,有三个几乎人人都会踩的坑。

难题 1:缓存失效与一致性(缓存和数据库怎么对上?)

数据库的数据变了,缓存里还是旧的,用户就读到了脏数据。怎么办?常见做法是「更新数据库后,把缓存删掉」(让下次读时重新从库里加载最新值)。但在高并发下,「更新库」和「删缓存」这两步之间的时序,会引出各种微妙的不一致。

这里没有完美解,只有取舍。 核心认知是:只要用了缓存,你就主动选择了「接受某种程度的不一致」。 你要做的是把不一致的窗口和概率,控制在业务能接受的范围内——比如给缓存设个合理的过期时间(TTL),让脏数据最多存在那么久。

难题 2:缓存穿透 / 击穿 / 雪崩(缓存没挡住,洪水直冲数据库)

缓存的使命是替数据库挡住流量。一旦这道墙在某个瞬间「漏了」,洪水直冲数据库,可能瞬间把它打垮:

  • 穿透:大量请求查一个根本不存在的数据(如不存在的用户 ID)。缓存里没有,每次都穿过去打数据库。常被恶意利用。→ 对策:把「查无此项」这个结果也缓存起来(空值缓存),或用布隆过滤器先挡一道。
  • 击穿:某个热点 key 恰好过期的瞬间,海量请求同时发现缓存没了,一起涌向数据库去重建。→ 对策:重建时加锁,只让一个请求去查库、其余的等它回填。
  • 雪崩:大量 key 在同一时刻集体过期(或缓存整体宕机),所有流量瞬间全压到数据库。→ 对策:给过期时间加随机抖动,别让它们同时到期;缓存本身也要做高可用。
   正常:   流量 ──▶ [缓存墙挡住大部分] ──▶ 少量漏到 ──▶ 数据库 (轻松)
   出事:   流量 ──▶ [墙突然漏了/塌了] ──────────────▶ 数据库 (被打垮)
            ↑                              ↑
       穿透/击穿/雪崩                 数据库扛不住而崩溃

难题 3:缓存与数据库的一致性(综合体现)

前两个难题最终都指向同一件事:缓存是数据的「第二份副本」,只要有副本,就有「两份对不上」的风险。 这与「主从复制有延迟」「分片后跨片不一致」本质是同一类问题——一旦你为了性能/扩展而复制了数据,一致性就成了必须管理的成本。

记住这个贯穿全章的主线:复制(从库)、分片、缓存,三者都是「制造数据副本/分身」来换扩展性,而代价都是同一个——一致性变难了。 天下没有免费的扩展。


六、为什么数据模型是「最难改」的决策

讲到这,该回到开篇那句话,把它讲透:逻辑好改,数据和状态难改。

  • 逻辑(代码)是「无状态」的:写错了?改几行,跑测试,重新部署,几分钟搞定。旧代码消失得干干净净,不留痕迹。
  • 数据是「有状态」的、有惯性的:你的数据模型(实体怎么划分、关系怎么建、用什么分片键、强一致还是最终一致)一旦定下来,就有海量真实数据按照它的样子,实实在在地躺在那里、并且每天还在增长

想改它,意味着:

   改代码:  旧代码 ──删除──▶ 新代码        (干净、可逆、几分钟)
   改数据:  几亿条旧数据 ──?──▶ 新结构

            必须:① 设计兼容方案(新旧结构怎么共存)
                  ② 写迁移脚本,搬运/转换海量数据(可能跑几天)
                  ③ 期间系统还得正常服务,不能停
                  ④ 出错了还要能回滚——但数据可能已经被改了一半
            (痛苦、危险、以「周」甚至「月」计)

数据迁移是工程界公认最高危的操作之一:它慢、它危险、它常常不可逆。越是底层的数据决策(分片键、核心实体关系、一致性级别),改动成本越是指数级上升。

所以,本章最重的一条建议:

数据模型与状态管理,要慎重、要尽早想清楚。 在「需求 → 约束 → 质量属性 → 取舍」(02 思考框架)这套流程里,「数据怎么建模、放哪、要多强的一致、怎么扩」应该是你最早、最认真思考的一部分。

这不是叫你「过度设计」去预支未来——而是说,在那些改起来代价极高的决策上(尤其是数据模型),值得多花时间在白板上想清楚,因为它们一旦错了,事后修复的代价可能是写代码的一百倍。该懒的地方懒(逻辑可以先糙后精),该较真的地方必须较真(数据模型要尽早想透)。


📌 真实案例:最终一致,从一篇论文开始

「最终一致性」不是偷懒的借口,而是 Amazon 在 2007 年 Dynamo 论文里系统提出的工程选择:购物车这种数据,可用性比强一致更重要(宁可让你看到旧购物车,也不能让你加不进购物车);而银行余额必须强一致。同一家公司,按数据分级选一致性——正是本章的核心主张。

  • 📎 Dynamo 论文解读
  • 而一致性「吹的」和「真做到的」常是两回事:Jepsen(Kyle Kingsbury 的项目)专门用故障注入实测各家数据库,在 20 多个系统里查出过一致性违规。教训:别轻信任何「我们是强一致」的宣传,要看它过没过 Jepsen。

本章小结

  • 核心论断:逻辑好改,数据和状态难改;无状态好扩,有状态难扩。状态是万恶之源(一切一致性/扩展/恢复难题的根),也是价值之源(产品价值就在数据里)。 架构的真正功夫,在于管好状态。
  • 数据放哪:为「访问形态」选存储——关系型(钱账关系/强一致)、文档型(灵活整取)、KV(极快小读写)、列存(海量聚合)、图(关系网络)、向量(语义检索)、对象(大文件)、搜索(全文检索)、时序(按时间聚合)。复杂系统天然多种存储混用。
  • 一致性是一条谱系:强一致(贵、严谨,给钱和库存用)到最终一致(便宜、高可用,给点赞数和动态流用)。CAP 的人话:网络分区迟早发生,届时只能在「一致」和「可用」间二选一。
  • 事务与 ACID(严谨派,偏 CP)对 BASE(务实派,偏 AP):成熟系统混用,核心交易强一致、周边数据最终一致。
  • 扩数据三张牌:复制扩读、分片扩写、缓存降延迟+扩读,但三者都靠「制造副本」,代价都是「一致性变难」。缓存还有失效一致性、穿透/击穿/雪崩三大坑。
  • 数据模型是最难迁移的决策,务必尽早想透。

承上启下:这一章我们反复在做一件事——为了「可扩展 / 高可用」,牺牲一点「一致性」。这其实就是一次次的取舍。一致性、性能、可用性、成本……这些「质量属性」彼此冲突,逼着你必须做选择。下一章 06 · 质量属性与取舍,我们把一致性放进这个更大的取舍棋盘里,看清楚:你永远不可能全都要。