ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从存储引擎到分布式事务:读DDIA掌握数据系统设计关键

从存储引擎到分布式事务:读DDIA掌握数据系统设计关键 简介DDIADesigning Data-Intensive Applications《设计数据密集型应用》中文翻译版面向后端工程师、架构师、DBA 与技术管理者系统梳理从底层数据结构到顶层系统架构的数据系统设计思路围绕数据模型、存储引擎、分区、复制、事务、一致性以及批处理与流处理等核心话题展开适合互联网数据密集型场景下的架构选型与实战复盘。资料主体为按章节拆分的 Markdown 笔记共 147 个文件包含 40 个 md 分章正文、103 张 png 示意图以及配合阅读的 Python/Pipfile 工具脚本与 LICENSE 说明压缩包约 25.21MB便于在 GitBook 或本地目录中按章学习。目前已获 782 人学习下载。读者可获得完整中文译稿、清晰的架构图解和可运行的辅助脚本内容注重概念来龙去脉与工程落地既能通读理论也能对照实际项目理解数据分区、复制、事务、一致性等关键机制与设计取舍。1. 为什么每个写后端的人都该啃一遍DDIA从一次分布式事务事故说起线上订单系统出现库存超卖排查到最后才发现是主从复制延迟导致的数据不一致面试被问你们系统怎么做分布式事务背完两阶段提交的定义却被追问那为什么生产环境没用2PC直接卡住。这两件事把我推到了DDIA面前。《设计数据密集型应用程序》这本被简称为DDIA的书不教你搭Kafka还是Redis集群而是把存储引擎、复制、分区、事务、共识这些地基问题拆开讲清楚。它适合后端开发、架构师和大数据工程师尤其是系统一过单机容量就反复出问题、想搞明白为什么的人。读完你未必能立刻设计出分布式系统但至少能说清每个组件为什么被设计成现在这样。2. 存储引擎选型LSM树和B树的取舍落在哪几个参数上2.1 从一条写入请求看LSM与B树的分岔口DDIA第3章把存储引擎讲得很透。我理解一条写入请求的路径是分岔口开始的一条UPDATE语句进来B树存储引擎先写WALWrite-Ahead Log然后找到对应的B树叶子节点页在内存里修改页再异步刷盘而LSM树存储引擎也是先写WAL但接下来是写进内存里的MemTable等MemTable满了才批量刷成不可变的SSTable文件。这个差异带来三个放大指标是选型时绕不开的参数写放大write amplification实际写入磁盘的字节数除以应用请求写入的字节数。B树原地更新写放大相对低LSM树因为有compaction一份数据可能被反复合并重写写放大通常高出数倍。读放大read amplification一次逻辑读需要触发的物理读次数。LSM树要查MemTable、多个SSTable文件甚至要查Bloom Filter来跳过不必要的文件读放大明显高于B树。空间放大space amplification磁盘上数据占用的空间与逻辑数据大小的比值。LSM树未compaction前会有大量冗余版本空间放大波动大。我在调参时会先确认系统的瓶颈是读还是写再对着三个指标选引擎而不是看哪个数据库名气大。2.2 一张表决定读多写多时选哪个引擎把DDIA的分析落成一张可抄的对照表是我在架构评审时反复用的工具维度B树InnoDB、PostgreSQL默认堆表索引LSM树RocksDB、Cassandra、LevelDB写路径原地更新页写放大低追加写SSTable写放大高需compaction读路径按主键走树查找行为稳定查多个SSTable读放大高压缩/合并页分裂与页合并Background Compaction需要调线程数与策略典型适用读多写少、事务型负载写多读少、大量顺序写、KV场景关键参数buffer pool大小、页大小16KB默认MemTable大小、Bloom Filter误判率、compaction触发阈值MemTable大小控制着刷盘频率默认64MB到128MB常见Bloom Filter的误判率每降低一个量级内存消耗约增加10%但能显著跳过不存在的SSTable。compaction的触发阈值决定后台合并多频繁阈值调太高写放大降了但空间放大涨了磁盘先爆。这些参数我都踩过调参的本质是拿读放大换写放大拿空间换延迟不存在全赢的配置。2.3 用sysbench把DDIA的结论验证一遍DDIA给了模型但模型要落到自己的硬件上才有意义。我一般用sysbench在同样的MySQL实例上分别跑只读和只写负载对比TPS差异# 准备数据8张表每表100万行 sysbench --tables8 --table-size1000000 \ --mysql-dbsbtest --mysql-userroot --mysql-passwordyourpass \ oltp_common prepare # 只写负载模拟大量插入与更新观察LSM类引擎路径下的写瓶颈 sysbench --tables8 --table-size1000000 \ --mysql-dbsbtest --mysql-userroot --mysql-passwordyourpass \ --threads16 --time120 --report-interval10 \ oltp_write_only run # 只读负载模拟点查与范围查观察B树叶节点命中率 sysbench --tables8 --table-size1000000 \ --mysql-dbsbtest --mysql-userroot --mysql-passwordyourpass \ --threads16 --time120 --report-interval10 \ oltp_read_only run--threads16是并发线程数--time120是压测时长--report-interval10每10秒打印一次TPS与延迟。只看平均值是不够的要取压测稳定段的P99延迟。我在一台4核8G的虚拟机上看过只读模式InnoDB能跑到6000TPS只写模式跌到2000不到和DDIA里B树读快写慢的描述吻合。用这个结果反过来推导要不要上RocksDB比拍脑袋选型靠谱得多。3. 复制与分区把数据横向拉开的两个杠杆怎么调参3.1 三种复制模式在节点宕机时的数据丢失窗口对比DDIA第5章把复制分成同步、异步、半同步三种我在生产环境里见过太多主库一宕业务数据回退几秒的事故本质都是复制模式选错了。三种模式的数据丢失窗口差别很大模式主库提交是否需要从库确认数据丢失窗口延迟代价同步复制全部从库确认后才返回零丢失从库与主库完全一致最慢一个从库卡住全链路卡住异步复制不需要任何从库确认主库宕机时丢失未复制到的增量最快主库不受从库影响半同步复制至少一个从库确认至多丢失未确认的部分窗口极小折中从库故障时自动降级我在MySQL里开半同步复制用的参数是rpl_semi_sync_master_enabledON和rpl_semi_sync_master_timeout1000后者表示等待从库确认的超时毫秒数。超过1秒自动降级为异步保证主库不阻塞但代价是丢失窗口重新打开。生产环境的配置不是选了半同步就万事大吉而是接受超时降级这个后备路径带来的风险。3.2 分区键的设计哈希分区与范围分区的参数陷阱DDIA第6章里有一句话让我印象很深分区的主要目标是均匀分布数据。哈希分区按key的哈希值取模或映射到区间均匀性最好但范围查询要跨所有分区范围分区按key的连续区间切分利于单分区扫描但热点key会打爆某一个分区。我在设计订单表分区时踩过坑按用户ID哈希分区数据均匀了但运营要按时间范围拉数据每次查询都要跨全部分区慢得离谱。后来改成按时间范围分区写入集中在当前分区读也集中看似高效结果大促时单一分区写入成了瓶颈。最后用了一个组合方案时间范围做主分区用户ID哈希做二级分片牺牲一点跨分区的灵活性换平稳。这里有个参数陷阱哈希分区数一旦定下来扩展分区时要迁移大量数据不能靠改配置解决。Cassandra的vnode和ClickHouse的rand()都有类似问题设计分区键时要先问一句这个键的基数够不够大会不会出现极端倾斜3.3 实操用docker-compose起一个三节点etcd验证Raft复制DDIA花了整章讲共识算法但纸上得来终觉浅。我习惯用docker-compose起一个三节点etcd集群亲手杀一个节点看复制状态怎么收敛version: 3 services: etcd1: image: quay.io/coreos/etcd:v3.5.4 command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380 --listen-peer-urls http://0.0.0.0:2380 --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd1:2379 --initial-cluster-token etcd-cluster --initial-cluster etcd1http://etcd1:2380,etcd2http://etcd2:2380,etcd3http://etcd3:2380 --initial-cluster-state new ports: [2379:2379, 2380:2380] etcd2: image: quay.io/coreos/etcd:v3.5.4 command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380 --listen-peer-urls http://0.0.0.0:2380 --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd2:2379 --initial-cluster-token etcd-cluster --initial-cluster etcd1http://etcd1:2380,etcd2http://etcd2:2380,etcd3http://etcd3:2380 --initial-cluster-state new ports: [12379:2379, 12380:2380] etcd3: image: quay.io/coreos/etcd:v3.5.4 command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380 --listen-peer-urls http://0.0.0.0:2380 --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd3:2379 --initial-cluster-token etcd-cluster --initial-cluster etcd1http://etcd1:2380,etcd2http://etcd2:2380,etcd3http://etcd3:2380 --initial-cluster-state new ports: [22379:2379, 22380:2380]这个配置的关键点在--initial-cluster-state new它告诉etcd这是全新集群而不是恢复现场首次启动必须带上集群初始化后再启动要改成existing。起好后用etcdctl验证# 查看集群成员确认3个节点都healthy etcdctl --endpointshttp://127.0.0.1:2379 member list # 写入一个key etcdctl --endpointshttp://127.0.0.1:2379 put ddia_test value123 # 从另外两个端点读确认复制到全部节点 etcdctl --endpointshttp://127.0.0.1:12379 get ddia_test etcdctl --endpointshttp://127.0.0.1:22379 get ddia_test # 手动停掉一个节点对应的容器再观察leader是否重新选举 # docker stop etcd2容器名 etcdctl --endpointshttp://127.0.0.1:2379 endpoint status --cluster -w tableendpoint status --cluster -w table会清晰展示每个节点的角色与term。我第一次在停掉etcd2后发现leader切换到了etcd1但写入仍能成功因为共识算法只要多数派活着就能继续提交这个多数派概念看一万遍书不如亲手验证一遍。注意不要一次性停掉两个节点三节点集群只剩一个就失去了多数派写入会直接失败这本身就是理解Raft容错边界的最好实验。4. 事务与一致性隔离级别要按业务异常容忍度来配4.1 四个隔离级别对应的四种异常拿SQL亲手复现DDIA第7章把隔离级别的演进讲得很清楚但记住定义和真在数据库里复现一次是不同的。我在MySQL里亲手复现过脏读和幻读才真正理解为什么默认隔离级别不是最高级别。脏读复现步骤MySQL需要把隔离级别调到READ UNCOMMITTED-- 会话A SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; UPDATE accounts SET balance balance - 100 WHERE id 1; -- 此时不COMMIT让会话B读取 -- 会话B SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT balance FROM accounts WHERE id 1; -- 会读到A尚未提交的余额脏读产生幻读复现步骤在REPEATABLE READ下-- 会话A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT COUNT(*) FROM orders WHERE amount 100; -- 会话B另一个连接 START TRANSACTION; INSERT INTO orders(amount) VALUES(200); COMMIT; -- 回到会话A再查一次 SELECT COUNT(*) FROM orders WHERE amount 100; -- MySQL默认RR下快照读看不到新插入的行但换成FOR UPDATE的当前读就能看到幻读出现脏读的根源是一个事务读了另一个事务未提交的数据把隔离级别调到READ COMMITTED就消失。幻读的根源是范围锁没有锁住未来要插入的行MySQL RR下靠间隙锁来防但只对当前读生效。DDIA里说得很明白隔离级别只是数据库提供的一组防御选项选哪个取决于你的业务能容忍哪种异常而不是无脑追求最高级别。4.2 从PostgreSQL到MySQL默认隔离级别的差异与后果DDIA指出每种数据库的默认隔离级别不同这个差异在真实业务里会埋雷。PostgreSQL默认是READ COMMITTEDMySQL默认是REPEATABLE READ。同样一段代码在PG上跑不会出现幻读在MySQL上RR下快照读不会出现但如果把MySQL的隔离级别改成READ COMMITTED又要小心binlog格式的配合问题。我在一个统计报表项目里踩过MySQL改成READ COMMITTED后某个统计SQL的count出现波动因为两次查询之间其他事务提交了数据。业务方不接受只能改回RR。后来把逻辑改成在事务里用SELECT COUNT(*) ... FOR UPDATE加锁才告别这种看起来对不上数的玄学。经验是不要在业务代码里依赖数据库隔离级别的具体行为你的SQL里必须显式写上FOR UPDATE或NOWAIT否则换个库或换个默认参数就翻车。4.3 分布式事务的边界2PC、TCC与本地消息表的取舍DDIA第9章没有给你银弹反而拆穿了分布式事务的复杂性。我见过团队上了Seata的AT模式结果协调者成为新的单点业务抖动时全局锁导致整个链路卡死最后不得不回退到本地消息表方案。方案一致性强度入侵性适用场景2PCXA强一致所有参与者同时提交或回滚高需要数据库支持XA协议跨库转账、金融核心链路但协调者本身要高可用TCCTry-Confirm-Cancel最终一致业务补偿极高每个操作都要写Try/Confirm/Cancel三段逻辑跨系统订单处理、资金账户操作业务上能接受中间态本地消息表最终一致靠重试低多一张消息表加一个定时任务下单发券、订单通知延迟容忍度高的场景本地消息表的做法是业务数据和消息放在同一个本地事务里写然后异步任务把消息推出去失败就重试。它不解决分布式问题而是从根上避免分布式事务。我现在的选型习惯是链路里涉及的钱、库存这类强一致数据才考虑TCC普通业务全部本地消息表加重试不为一个下单通知引入2PC这种高成本方案。5. DDIA读与用避坑指南五个让我翻过车的理解误区5.1 现象把CAP当成三选二面试和设计文档里最常见的翻车就是把CAP理解成一致性、可用性、分区容错性三者只能选两个。DDIA第7章专门批评了这种粗浅说法CAP里的P是网络分区它不是一个可选项而是一个必然会出现的故障条件。分区发生时你才需要在C和A之间取舍没有分区时三者可以同时满足。原因把CAP当定理背没理解它描述的是特殊故障场景而不是常态下的设计权衡。解决先接受网络分区是常态然后在分区发生时回答我要优先保证读到旧数据还是优先保证继续写入。这个取舍落到具体系统里就是复制模式的选择和一致性级别的配置而不是画一个三角图。5.2 现象把Raft当成分布式事务的后悔药有段时间我觉得只要引入一个基于Raft的元数据服务所有一致性难题都解决了。结果在做订单分库分表时把路由信息放在etcd里服务启动时读一次随后就再没同步路由变更导致部分请求打到错误分片。Raft解决的是多个节点对同一个日志达成一致它不负责业务数据跨库事务。原因混淆了共识consensus和事务transaction。Raft确认的是这条日志被大多数节点存储而分布式事务确认的是多个参与者要么都提交要么都回滚。解决Raft用在选主、元数据同步、配置下发这些场景不要指望它替代2PC或TCC。我自己现在的检查单里有一条凡是涉及业务数据的多写操作先问一句这是共识问题还是事务问题。5.3 现象直接照搬生产库的默认隔离级别刚带项目时我习惯让DBA用默认隔离级别觉得默认值是官方调过的。结果一个支付对账场景在RR下出现锁等待超时排查半天才发现是间隙锁在重叠范围上相互阻塞。默认隔离级别是出于兼容性和历史原因选的不是最优解。原因不了解默认值背后的兼容性负担。MySQL默认RR是为了配合老版binlog格式PostgreSQL默认RC是为了让每个语句看到最新已提交数据。解决上线前做一个异常容忍度评审脏读、不可重复读、幻读业务分别能不能接受。把答案写进设计文档再反推隔离级别。如果业务只关心最终一致RC常常比RR更合适锁竞争更小。5.4 现象跳过第3章直接啃分布式部分很多人翻开DDIA直接看复制、分区、事务觉得存储引擎是DBA的事。我最初也这样结果看第五章复制日志时完全理解不了为什么LSM适合跨节点同步、B树却有很多限制。后来回头补第3章才明白SSTable的不可变性和合并策略正是它适合做日志复制的天然属性。原因把存储引擎和分布式系统拆成两门课但DDIA的叙事主线恰恰是单机存储的物理特性决定分布式协议的上层选择。解决第一遍读DDIA老老实实从第3章开始后面的内容会顺畅很多。5.5 现象拿书里的数字当性能基准DDIA里有很多数量级的对比比如SSD比HDD快多少倍、写入放大通常是多少。我一度把LSM写放大5~10倍当成自己的系统也会如此结果压测出来RocksDB的写放大到了20倍因为compaction参数没调。书里的数字是作者实验室环境下的参考值不是你的SLA。原因把教材案例当成了本机基准忽略了硬件型号、数据分布、负载模型的影响。解决每到一个新环境先跑一遍DDIA对应的实验记录自己的数字再对照书里的量级看是否合理。这样才能判断我的系统是不是病态而不是硬套结论。6. 把DDIA读成行动清单一组架构评审时的必查项读完DDIA不意味着架构能力自动升级我现在的习惯是把书里的模型转成评审清单每次做技术选型时逐条过写路径是先写日志还是原地更新写放大估算过没有复制是同步还是异步主库宕机后的RPO恢复点目标是多少秒分区键的基数够不够有没有可能在一个分区里倾斜业务能容忍哪种事务异常隔离级别是按默认值还是评审值配的多个系统间的数据一致性是最终一致还是强一致用的机制是什么共识协议选型时容忍几个节点故障多数派边界算清楚了吗这个清单帮我挡掉过至少三次危机一次是消息队列选型时发现客户端重试可能导致重复消费一次是缓存与数据库一致性方案里缓存先删还是先更新搞反了。现在每个新项目启动我会先找出DDIA对应章节把涉及的关键参数记在一张卡片上再开始写代码。这个习惯带来的收益远大于读三遍书。希望帮到你少走我走过的那些弯路。本文还有配套的精品资源点击获取
返回列表