沿“单机存储引擎 → 复制 → 分区 → 事务 → 不可靠网络与时钟 → 一致性与共识 → 批处理与流处理”的演进主线,理解每个数据组件背后的原理与权衡。来源:《数据密集型应用系统设计》(Martin Kleppmann,中国电力出版社)。
一致性一词装了两个需求,原书主张拆开:时效性指用户能观察到最新状态——读到旧副本只是暂时不一致,等待重试可解,线性化是它的最强形式;完整性指数据不丢、不矛盾——派生视图必须如实反映数据源,一旦破坏就是永久性不一致,等待无用,必须专门检查修复,原子性与持久性正是保障完整性的手段。一句话:违反时效性得到最终一致,违反完整性得到永久损伤。异步派生天然牺牲时效性,可用重试与对齐位点弥补;而按序应用、恰好一次、请求级幂等守护完整性,是不可让步的底线。多数应用里完整性远比时效性重要。
各层去重各管一段:TCP 靠序列号去重但只在本连接内有效;断连重试后是新连接新事务;浏览器重复提交表单更是全新请求——数据库事务再正确也拦不住端到端的重复。解决要在端到端做:为每个操作生成全局唯一标识(隐藏表单字段或表单哈希),随请求一路传递;服务端建请求表加唯一约束,处理时先插入请求 ID,冲突即说明已执行、返回原结果。关键细节:弱隔离下唯一约束依然可靠,先查后插会被并发击穿——判重必须交给约束。跨分区时先把带 ID 的请求追加为单条日志消息,再派生各分区指令并按同一 ID 去重,无须跨分区原子提交。
批处理容错的底气是失败任务的部分输出可丢弃,重跑后输出与没出错时一致,效果上每条输入恰好处理一次。流处理借同一思路但必须改形:把流切成小块当微型批处理(秒级,粒度是延迟与调度开销的折中),或定期给状态打检查点、故障后从检查点回放并丢弃其后输出,框架内部由此获得恰好一次;一旦输出离开框架(写外部库、发邮件、移偏移量),副作用就会重复。补全路径有二:把状态变更与消息发送纳入框架内事务;或靠幂等——值连同触发写入的消息偏移量一起落库,重放时先比对判重。状态恢复靠本地快照或日志压缩主题重放。
事件时间指事情实际发生的时刻(事件自带时间戳),处理时间指流处理器看到它的时刻。排队、网络故障、消费者重启、重放历史都会拉开两者,且消息可能乱序到达。用处理时间开窗会制造假象:处理器重启后清理积压,按处理时间统计会看到不存在的流量尖峰。改用事件时间开窗,又面临何时认定窗口完成的难题——无法确定迟到消息还有没有,只能超时关闭并忽略(同时监控丢弃量),或关闭后发布更正值收回旧结果。给事件定时间戳也有陷阱:移动设备时钟不可信,可用三个时间戳估算偏移加以校正。窗口有轮转、跳跃、滑动、会话四种形态。
两者都把系统状态表达为追加日志,抽象层次截然不同。CDC 捕获数据库的低层行变更,应用照常随意增删改,日志只是复制的副产品,应用毫不知情。事件溯源把用户行为作为一等公民:只能追加语义化事件(如学生取消选课),更新删除被禁止,当前状态由重放事件积分而来;新功能可带新视角重放全部历史。它还严格区分命令与事件:用户请求先是可能失败的命令,通过验证并落盘后才成为不可变事件,任何消费者无权拒绝。日志压缩的适用因此不同:CDC 事件携带整行新值,每键留最新一条即可;事件溯源的后来事件不覆盖先前事件,重建需全史。
应用同时写数据库又写索引的双重写入必然出事:两个客户端的写在不同系统以不同顺序落地,两边永久不一致且不报错。治本思路是变更数据捕获——把数据库的全部变更(其复制日志)作为事件流发布,索引、缓存、数仓作为消费者按同序应用,等于数据库当主节点、派生系统当从节点;解析复制日志比触发器健壮。历史日志存不全时用一致性快照起步、对齐日志位点后追赶;日志压缩更进一步——每键只保留最新值,重建派生系统从压缩日志头部扫描即可拿到全量现状。CDC 是异步的,复制滞后的所有问题在此同样存在。
传统消息代理(AMQP/JMS 风格)本质是阅后即焚的队列:消息确认传递后即删除,新消费者只见注册后的消息,同一消费者无法重跑。基于日志的代理把消息写进仅追加的分区日志:生产者追加,消费者按序读取;分区突破单盘吞吐——分区内靠偏移量完全有序,分区之间不保证。消费是只读操作,进度就是消费者记录的偏移量,拨回去即可重放历史,天然支持多订阅者回看;负载均衡以整个分区为单位分配给消费者组。日志容量受磁盘约束、写满删旧段,相当于很大的磁盘环形缓冲,通常能存几天到几周。
批处理把无界数据切成一天或一小时的片定期跑,代价是变更要等下个周期才反映到输出;把周期缩到极限、事件一发生就处理,就是流处理。流的基本单位是事件:小而不可变、带时间戳的记录,由生产者发布到主题、被多个消费者订阅;数据库轮询不适合低延迟通知,于是发展出专门的消息系统。有了流可做三类事:写入数据库、缓存、索引供查询;推送给用户(告警、仪表板);最核心的是消费输入流、产出输出流的多级流水线。流与批的实质差异只有一条:输入永不结束,它动摇排序与容错两大根基。
并行 join 这些算法 MPP 数据仓库早已实现,分野在开放性。存储上,MPP 要求先按专有格式建模导入;Hadoop 的文件系统只认字节序列,什么格式都能先倒进来——数据湖先转储后建模,解释负担从生产者转到消费者。处理上,MPP 用 SQL 做探索分析;Hadoop 允许跑任意代码,机器学习等非 SQL 负载同群可跑,多种负载共享同一批机器与数据。容错哲学也不同:MPP 节点失败就中止整个查询重跑,适合分钟级查询;MapReduce 按任务粒度重试,经得起数小时长作业——原书说这源于 Google 混部环境任务随时被抢占。
把批处理结果逐条写进在线库是最直觉也最糟的做法:每条一次网络请求比批处理吞吐慢几个数量级;成百上千并行任务同时写一个库直接压垮它;更根本的是破坏了全有或全无保证——作业失败时外部副作用已经生效,无法丢弃。正确姿势是让作业输出构建成全新的数据库或索引文件写入分布式文件系统,再由服务端批量加载:旧文件保留,复制完成后原子切换,出问题随时切回。背后哲学与 UNIX 一脉相承:输入不可变、除输出外无副作用,代码写坏也能回滚重跑——原书称为人为容错性,最小化不可逆让团队敢快速迭代。
批处理 join 面向为全体用户处理数据的场景,逐条查远程库既慢又危险,应把两边数据都放进分布式文件系统。reduce 端 join 通用:两边各起 mapper 提取 join 键,框架按键分区排序,同键记录在 reducer 相邻出现,配次级排序让维度记录先到,一遍扫完——代价是完整 shuffle。map 端 join 跳过排序与 reducer,但对输入有前提:小表能装内存用广播哈希 join;两边按同键同方式分区用分区哈希 join;同分区同序可用合并 join。选型看物理布局,热键倾斜则需打散与两阶段聚合。
MapReduce 作业固定四步:输入分解成记录、逐条调映射函数产出键值对、按键排序、同键值交给归约函数。用户只写两端的回调,排序由框架隐式完成——这是最易被忽略的关键。排序分两段:每个 map 任务把输出按归约任务数量分区、各自排序后写本地磁盘;map 全部完成后,reduce 任务从所有 map 节点拉取属于自己的份,归并成全局有序再调用。这套分区、排序、复制的过程就是 shuffle。并行度来自分区:map 数由输入块数决定,reduce 数由作者配置;调度器让计算靠近数据。
系统按交互方式分三类:在线服务等请求、看响应时间;批处理吃进大量输入跑作业、看吞吐量;流处理居中,事件发生后不久即处理。原书从 UNIX 管道讲起,因为 MapReduce 继承了它的衣钵:每个程序只做好一件事;输出设计成另一个未知程序的输入;尽早构建原型、舍得扔掉;优先写工具。可组合性的根基是统一接口——一切皆字节流,程序只认标准输入输出,处理逻辑与接线分离;输入不可变、无副作用,随时可重跑、可查看中间结果。MapReduce 把这套哲学搬到数千台机器:作业即进程,分布式文件系统即文件接口。
ZooKeeper、etcd 这类协调服务把共识算法包装成开箱即用的原语,专存少量、可全载内存的元数据,别当通用数据库。四件关键能力:线性化的原子比较并设置(分布式锁的基础);操作全序与单调递增的事务标识(现成的 fencing 令牌);基于会话心跳的故障检测(超时自动删临时节点、释放其锁);变更通知(订阅变化免轮询)。典型用途是多实例服务选主、任务接管、分区分配与成员管理;服务发现未必需要共识。形态是三五台固定节点外包全集群的协调。原书忠告:自己实现共识几乎注定失败,用经过验证的系统。
共识问题形式化为:节点提议值,算法最终决定唯一值,满足四条性质——协商一致、诚实性(不两次决定)、合法性(值确有人提议)、可终止性(不崩溃就出结果)。前三条是安全性,独裁节点即可满足;可终止性才是容错关键,要求多数节点存活。主流算法内部仍有主节点,靠世代编号防脑裂:每次选主编号递增,竞争时高者胜;旧主做决定前必须重新向法定节点收集投票,发现有更高世代即让位。安全来自两轮投票(选主与提议)的法定集合必须重叠,旧主无法悄悄通过已被取代的决定。与两阶段提交差异:协调者可换、多数即通过、内建恢复。
跨节点事务不能各自提交——部分成功部分失败即破坏原子性。两阶段提交引入协调者:先发准备请求,参与者把数据稳妥落盘后回答是,从此立下不反悔的承诺;协调者收齐全票、把最终决定写入自己的磁盘日志(提交点),再下发提交或放弃。原子性靠两道不归路:参与者投是后无权单方面放弃,协调者落盘决定后必须重试到成功。软肋随之而来:协调者在下发前崩溃,已投票的参与者陷入既不能提交也不能回滚的不确定状态,只能持锁干等恢复——所以 2PC 是阻塞式协议。工程层的 XA 还有停顿事务长期持锁、恢复失败靠人工裁决等账。
因果关系给事件施加偏序:问在答前、发送在接收前;互相不知晓的并发事件不可比。线性化把所有操作压成全序时间线、蕴含因果;只要因果序被遵守,并发事件可任意排——因果一致性是不因网络延迟受损、又能容错的最强模型。编号有讲究:奇偶号、墙上时间戳、区间分配都可能违背因果;Lamport 时间戳靠见过更大计数就向前跳保证因果一致,但只看编号分不清两事件是并发还是因果。真正解决排序的是全序广播:所有节点以相同顺序恰好一次地交付消息。它是状态机复制的骨架,与线性化原子操作、共识互相实现、本质等价。
线性化在正常网络下可用性能换,分区时则成单选题:两机房断联,坚持线性化则连不上主节点的一侧必须拒写(保一致舍可用);允许各自写则服务可用但副本分歧,违背线性化。这就是 CAP 的实质——但正确读法与流行版不同:分区不是可选项,它是随时发生的故障;真正的取舍只发生在分区期间,选一致还是选可用。且正式 CAP 定理范围很窄(只谈线性化与网络分区),泛化成三选二的口诀反而误导,原书建议慎用。即便无分区,线性化也昂贵:有结论证明其读写延迟下限与网络延迟成正比,多核内存与多数数据库放弃它是性能选择。
两个词都含可以排成顺序之意,却是正交维度。可串行化是事务隔离属性:多个事务读写多个对象,结果等价于某种一次一个的串行执行——但不承诺该顺序符合真实时间先后。线性化是单个寄存器上读写的最新值保证:读到的必须不早于任何已完成的更早操作,蕴含实时顺序。四个象限都存在:两者皆无(弱隔离加最终一致);只有可串行化(SSI——从快照读,故意不线性化);只有线性化(无事务的单键 CAS);两者兼有即严格可串行化(两阶段加锁、实际串行执行)。口诀:串行化管事务像不像串行,线性化管单个对象是不是最新。
线性化是最强的一致性模型,目标是让多副本系统对外表现得像只有一个数据副本、操作原子生效。含义靠两点刻画:操作的实际生效点落在发出请求与收到响应之间;一旦任何读看到新值,之后所有读都必须返回不旧于它的值,生效点连线只进不退。原书的比分例子:朋友已欢呼夺冠,你刷新却看到比赛进行中,即违反线性化。真正需要它的场景:锁与选主、硬性唯一约束、跨通道的时间依赖——系统间存在复制之外的沟通渠道时,没有线性化就有竞争条件。主从复制部分满足,共识满足,多主不满足,无主即便严格 quorum 也未必。