# 05 · 数据与状态:系统真正的难点 > 一句话点题:**逻辑好改,数据难改。** 代码写错了,改一行重新部署就行;但当几亿条数据已经按某种结构躺在库里、用户的状态正实时变动时,你想改它——那是要命的。**架构的真正难点,从来不在逻辑,而在数据与状态。** --- ## 为什么说「状态是万恶之源,也是价值之源」 先建立一个最重要的直觉:把系统里的东西分成两种—— - **无状态(Stateless)**:它不记得任何事。你给它输入,它算出输出,算完就忘。比如一个「把华氏温度换算成摄氏」的函数,或者一个只负责转发请求的网关。 - **有状态(Stateful)**:它记得事情,而且这些记忆会随时间变化。比如「用户的账户余额」「购物车里有什么」「这个房间里现在有谁在线」。 这两者在「**能不能轻松扩容**」上,是天壤之别: ``` 无状态组件:像便利店收银员,谁来都一样,不够就再加几个 ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ 实例│ │ 实例│ │ 实例│ │ 实例│ ← 想扩容?直接复制一份,流量随便分 └────┘ └────┘ └────┘ └────┘ 它们之间不需要互相知道任何事 有状态组件:像图书馆里那本独一无二的孤本 ┌──────────────────┐ │ 它"记得"东西 │ ← 想复制一份?那两份的"记忆"怎么保持一致? │ (余额/库存/会话) │ 复制就有同步问题,这就是一切麻烦的根源 └──────────────────┘ ``` > **无状态组件好扩,有状态组件难扩。** 这是分布式系统的第一性原理之一。 所以你会看到一个反复出现的架构动作:**想方设法把「状态」从「计算」里挤出去,集中关进少数几个专门管状态的地方(数据库、缓存),让其余组件尽量保持无状态、可以随便复制。** 这样系统的大部分都能轻松水平扩展,只把「难搞的状态」收拢到可控的几处去精心伺候。 但状态也绝不是只有坏处。说到底: > **状态是万恶之源——所有的一致性难题、扩容难题、故障恢复难题,根子都在它。** > **状态也是价值之源——用户的数据、订单、关系、记忆,正是产品的全部价值所在。** 架构师的工作,不是消灭状态(消灭不了),而是**把状态管理得明明白白**:哪些数据放哪、要多强的一致、怎么扩、怎么在故障时不丢不乱。这一章就讲这些。 --- ## 一、数据放哪:不同的「访问形态」,对应不同的存储 新人最常见的误区是「**我会用某个数据库,就拿它装一切**」。这就像不管装什么都用同一种盒子——装液体、装螺丝、装衣服全用一个,必然别扭。 正确的思路是:**先看这份数据「长什么样、怎么被访问」,再选最适合它的存储。** 这叫「**为访问形态选存储**」。下面是架构师工具箱里最常见的几类存储,各用一两句话讲清「**什么样的数据 / 访问适合它**」: | 存储类型 | 最适合什么样的数据 / 访问 | 一句话直觉 | |---|---|---| | **关系型(Relational)** | 结构清晰、关系复杂、需要**事务和强一致**的核心数据(用户、订单、账户、库存) | 「钱、账、关系」的默认家;要严谨就找它 | | **文档型(Document)** | 结构灵活、自包含、按 ID 整取整存的数据(一篇文章、一份配置、一条会话记录) | 一坨「半结构化的 JSON」,字段常变就用它 | | **键值(KV)** | 极简的「按 key 取 value」,要求**极快**(会话、计数、缓存、特征) | 一本超大字典,要的就是闪电般的读写 | | **列存(Columnar)** | 海量数据上的**分析聚合**(报表、统计「过去一年各地区销量」) | 按列存,适合「扫一大片、算个总和/平均」 | | **图(Graph)** | 重点在**关系网络**本身的数据(社交关系、推荐、风控关联、知识图谱) | 「谁认识谁、谁连着谁」,查关系路径的专家 | | **向量(Vector)** | 要按**语义相似度**检索的数据(文本/图片的 embedding,用于 RAG、推荐) | 「找跟这个意思最像的」,普通数据库做不到 | | **对象存储(Object)** | 大块、不可变、按 ID 整取的**文件**(图片、视频、模型权重、备份) | 海量大文件的仓库,便宜、能装、不改 | | **搜索引擎(Search)** | 需要**全文检索 / 复杂条件过滤排序**的数据(商品搜索、日志检索) | 「输入关键词,模糊地找出相关的一堆」 | | **时序(Time-Series)** | 带时间戳、**只追加、按时间聚合**的数据(监控指标、传感器、计费流水) | 一条不断增长的时间轴,擅长「按时间段算趋势」 | > 注意:**一个稍微复杂的系统,几乎一定同时用好几种存储。** 这不是「不够纯粹」,恰恰是成熟的标志——这叫「多语言持久化(polyglot persistence)」,即「让每种数据待在最适合它的家里」。 > > 回想 [AI 对话产品模板](../templates/ai-chat-product/README.md) 的第 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 章](04-十大核心架构模式.md) 里微服务最大的痛吗?跨服务事务几乎不可能)。 于是另一套思路应运而生——**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 思考框架](02-架构师的思考框架.md))这套流程里,「数据怎么建模、放哪、要多强的一致、怎么扩」应该是你**最早、最认真**思考的一部分。 > > 这不是叫你「过度设计」去预支未来——而是说,在那些**改起来代价极高**的决策上(尤其是数据模型),值得多花时间在白板上想清楚,因为它们一旦错了,事后修复的代价可能是写代码的一百倍。**该懒的地方懒(逻辑可以先糙后精),该较真的地方必须较真(数据模型要尽早想透)。** --- ## 📌 真实案例:最终一致,从一篇论文开始 「最终一致性」不是偷懒的借口,而是 Amazon 在 2007 年 **Dynamo 论文**里系统提出的工程选择:**购物车这种数据,可用性比强一致更重要**(宁可让你看到旧购物车,也不能让你加不进购物车);而银行余额必须强一致。同一家公司,按数据分级选一致性——正是本章的核心主张。 - 📎 [Dynamo 论文解读](https://www.dynamodbguide.com/the-dynamo-paper/) - 而一致性「吹的」和「真做到的」常是两回事:[**Jepsen**](https://jepsen.io)(Kyle Kingsbury 的项目)专门用故障注入实测各家数据库,**在 20 多个系统里查出过一致性违规**。教训:别轻信任何「我们是强一致」的宣传,要看它过没过 Jepsen。 --- ## 🎯 随堂检验 --- ## 本章小结 - **核心论断**:**逻辑好改,数据和状态难改;无状态好扩,有状态难扩。状态是万恶之源(一切一致性/扩展/恢复难题的根),也是价值之源(产品价值就在数据里)。** 架构的真正功夫,在于管好状态。 - **数据放哪**:为「访问形态」选存储——关系型(钱账关系/强一致)、文档型(灵活整取)、KV(极快小读写)、列存(海量聚合)、图(关系网络)、向量(语义检索)、对象(大文件)、搜索(全文检索)、时序(按时间聚合)。复杂系统天然多种存储混用。 - **一致性是一条谱系**:强一致(贵、严谨,给钱和库存用)到最终一致(便宜、高可用,给点赞数和动态流用)。CAP 的人话:**网络分区迟早发生,届时只能在「一致」和「可用」间二选一。** - **事务与 ACID**(严谨派,偏 CP)对 **BASE**(务实派,偏 AP):成熟系统混用,核心交易强一致、周边数据最终一致。 - **扩数据三张牌**:**复制扩读、分片扩写、缓存降延迟+扩读**,但三者都靠「制造副本」,代价都是「一致性变难」。缓存还有失效一致性、穿透/击穿/雪崩三大坑。 - **数据模型是最难迁移的决策,务必尽早想透。** > **承上启下**:这一章我们反复在做一件事——为了「可扩展 / 高可用」,牺牲一点「一致性」。这其实就是一次次的**取舍**。一致性、性能、可用性、成本……这些「质量属性」彼此冲突,逼着你必须做选择。下一章 [06 · 质量属性与取舍](06-质量属性与取舍.md),我们把一致性放进这个更大的取舍棋盘里,看清楚:**你永远不可能全都要。**