ARTICLE DETAIL

资讯详情

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

CockroachDB读写路径拆解:从SQL到磁盘的分布式一致性代价

CockroachDB读写路径拆解:从SQL到磁盘的分布式一致性代价 上周有个同事问我为什么在 CockroachDB 里跑一条主键点查只要两三毫秒而写一条同样大小的记录却要十几毫秒多写几行差距还会继续拉开。这个问题问得挺典型它其实戳中了 CockroachDB 架构里一条最关键的分界线读和写在分布式这一层走的是完全不同的路径。读基本可以在一个副本上就地解决写却绕不开 Raft 的多数派确认和两阶段提交。很多人第一次接触 CockroachDB会下意识拿它和 MySQL 做类比觉得不就是一个能分片的 MySQL结果一上量就发现延迟分布完全不对写的 P99 抖动远大于读这正是因为底层的一致性协议在读写路径上的分工不一样。这篇内容我打算把 CockroachDB 的读路径和写路径拆开从 SQL 语句一路追到磁盘把中间每一站的职责、代价和踩坑点讲清楚。适合已经有单机数据库基础、正准备把 CockroachDB 用进生产环境或者正在排查分布式读写延迟的读者。文中涉及的参数默认值我会标注大致的版本区间因为 CockroachDB 迭代很快具体以你手上的版本文档为准。1. 一条写入请求在 CockroachDB 里到底走了几站理解写路径是理解整个架构的地基。CockroachDB 对外是一个 SQL 数据库对内其实是一个巨大的、有序的分布式 KV 存储SQL 层只是架在上面的一层翻译器。写操作从客户端发出到真正落盘中间要经过编码、路由、Raft 复制、意图写入、提交决议这么几站每一站都有自己独立的延迟和失败模式。1.1 SQL 行怎么被翻译成有序的 KV 键值对CockroachDB 里每一张表的每一行最终都会被编码成一个或多个有序的键值对。主键索引的键长这样/Table/tableID/indexID/主键值/列族ID二级索引则是/Table/tableID/indexID/索引列值/主键值。举个例子表CREATE TABLE t (id INT PRIMARY KEY, name STRING)插入id 1001之后你可以用下面这段 SQL 直接看到它在 KV 层的样子SELECT crdb_internal.pretty_key(raw_key, 0) AS pretty_key, crdb_internal.pretty_value(raw_value) AS pretty_value FROM crdb_internal.kv_store_status; -- 仅用于观察生产环境别乱跑大扫描更直接的方式是看 range 信息SHOW RANGES FROM TABLE t; SHOW RANGE FROM TABLE t FOR ROW (1001);这里有一个非常关键的设计点因为是有序编码主键顺序和物理存储顺序是一致的所以范围扫描是连续的磁盘读取效率很高但也正因为有序会产生热点 range的问题——如果主键是自增的 INT所有新写入都会落在最后一个 range 上整个集群再多的节点也帮不上忙。这就是为什么官方一直推荐用 UUID、gen_random_uuid()或者有自然散列特性的业务键做主键。我见过太多团队从 MySQL 迁过来顺手把AUTO_INCREMENT改成SERIAL然后发现写入吞吐怎么加节点都上不去根因就在这里。另外要提醒一个细节一次 INSERT 往往不止写一个 KV 对。主键索引一对每一个二级索引还会各写一对如果有外键或者唯一约束还会额外读一次做冲突检查。所以写一行这个说法在存储层是不成立的实际放大倍数取决于你建了多少索引。索引不是免费的每多一个二级索引写入链路上就多一份 Raft 复制的量。1.2 Range、Raft 组与多数派这道硬门槛键空间被切成一段段连续的区间每一段就是一个Range。默认配置下 range 大小上限是 512MiB下限 1MiB超过上限就会自动分裂成两个。每个 range 是一个独立的Raft 复制组默认三副本分散在三个不同的节点上。这个设计的好处是元数据不用像传统分片那样依赖一个中心节点坏处是每个 range 都要自己选主、自己维护日志。一次写入到达某个 range 后流程大致是请求先被路由到该 range 的Leaseholder也是 Raft Leader。Leaseholder 做一致性检查、写入写意图Write Intent然后向 Raft 日志追加一条命令。这条日志被复制到多数派副本3 副本场景下就是至少 1 个 follower。多数派确认后日志被标记为committed各副本把命令 apply 到本地存储引擎。注意第 3 步是整条链路上最贵的一环它需要至少一次跨节点的网络往返。这就是写比读慢的根本原因——读可以只在一台机器上完成写必须等另一台机器点头。所以 CockroachDB 的写入延迟下限基本等于集群内节点间 RTT 磁盘 fsync 时间同机房内通常是几毫秒级别。如果你的写入延迟长期在 50ms 以上先别急着怀疑数据库去查一下节点之间是不是跨了可用区甚至跨了地域。我实测过一个很典型的情况三副本分布在同城三个机房两点之间 RTT 约 8ms写入 P50 就稳定在 15ms 左右这个数基本就是物理下限。任何号称分布式强一致还能做到 1ms 写的说法都值得怀疑一下它的副本是不是都堆在一台机器上。1.3 写意图 Intent为什么写入天然是两阶段CockroachDB 默认的隔离级别是SERIALIZABLE事务实现参考的是 Spanner 那一套但用的是无中心的 MVCC 乐观并发控制。写入过程并不是直接改数据而是先在数据上打一个写意图——本质是一个指向事务记录的指针标记这个键在事务 T 的某个时间戳上将要被改成某个值。完整的过程分两阶段阶段一写意图所有涉及的 range 上分别写入 intent并通过 Raft 提交。阶段二提交在事务记录所在的那个 range 上把事务状态改成COMMITTED然后异步地清理各个 range 上的 intent把它们落实为正式版本同时把旧版本推进 MVCC 的历史里。有一项重要的优化叫Parallel Commitsv19.2 起默认开启当事务涉及的 range 数量较少时可以把写意图和提交状态合并成一次 Raft 提案省掉一次网络往返。这也是为什么 CockroachDB 的单 range 事务延迟和跨 range 事务延迟差别明显——跨的 range 越多需要协调的副本组越多提交阶段的往返次数就越多。提示事务不要写得太宽。一个事务涉及的 range 数量直接决定了提交阶段的协调成本也决定了冲突检测的复杂度。我通常建议把单个事务控制在 100 行以内、range 跨度尽量小范围内批量导入场景优先用分批小事务而不是一个巨大的事务。1.4 Pebble 与 LSM写给磁盘的那部分代价apply 阶段真正把数据写下去的是存储引擎Pebblev20.2 起取代了早期的 RocksDB。Pebble 是一个 LSM-Tree 结构的引擎写入路径是先写 WAL预写日志保证崩溃可恢复再写 MemTableMemTable 满了之后刷成 L0 的 SST 文件后台再通过Compaction把小的 SST 合并成大的、有序的 SST。这套结构对写入很友好但代价是写放大一条逻辑写入会在 WAL、MemTable flush、多次 Compaction 里被反复搬运。写放大倍数和负载形态高度相关持续高写入压力下放大到十几倍都不稀奇。所以当你发现磁盘 IO 被打满、写入延迟徒增但应用侧 QPS 并没有明显变化时八成是 Compaction 跟不上导致的。这种情况先看磁盘饱和度和 L0 文件数量再考虑限流或者扩容而不是一味加索引优化。2. 读路径的岔路口Leaseholder、闭时间戳与 Follower Read读路径比写路径花样多因为 CockroachDB 给读提供了好几个档位从最强一致的 Leaseholder 读到稍微牺牲一点新鲜度的 Follower Read再到显式指定历史时间点的AS OF SYSTEM TIME。选错档位要么白白浪费延迟要么拿到不该拿的数据。这一节把每个档位的原理和适用边界讲透。2.1 为什么读默认落在 Leaseholder 上每个 range 会有一个副本持有Lease这个副本叫 Leaseholder。持有租约期间它有权在不询问其他副本的情况下直接处理读请求——因为租约保证了在这个时间窗口内不会有别的副本被选成新的 Leaseholder也就不会出现两个副本同时对外提供读的情况。这就是读快的核心原因读不需要 Raft 往返。请求直接打到 Leaseholder它检查一下时间戳缓存然后从本地 Pebble 里按 MVCC 时间戳取出版本返回。整个过程只涉及一台机器的 CPU 和磁盘。但要注意Leaseholder 的位置是动态的。如果某个节点负载过高、网络分区或者被下线租约会转移。对应用来说这意味着读请求的路由会变gateway 节点需要重新查元数据 range 才知道某个 range 的 Leaseholder 在哪台机器上。所以你会看到读延迟偶尔出现几百毫秒的尖刺然后立刻恢复——这种情况往往不是数据库出问题而是发生了租约转移。-- 看一下某张表的 range 都分布在哪些节点上 SELECT range_id, start_key, end_key, lease_holder, replicas FROM crdb_internal.ranges WHERE table_name t;2.2 闭时间戳让只读请求免协调的底座CockroachDB 里有一个非常优雅的机制叫Closed Timestamp闭时间戳。简单说Leaseholder 会持续向它管理的副本宣告时间戳 T 之前的所有写入我保证已经不会再有了。这个 T 就是该 range 的闭时间戳。默认情况下这个推进节奏由kv.closed_timestamp.target_duration控制默认大约 3 秒。有了闭时间戳之后只读事务就能做一件很爽的事它不需要申请一个全局新鲜的时间戳而是直接用一个已经关闭的、稍微旧一点的时间戳去读。因为任何副本上都保证收到过 T 之前的全部写入所以这个读在任何一台副本上执行结果都一致不需要联系 Leaseholder也不需要走一致性协议。这就是为什么 CockroachDB 的只读事务可以做到几乎零协调开销。你只要把事务标成只读它拿到的就是一个闭时间戳读多快取决于本地磁盘。实践中我经常建议报表、导出、校验这类逻辑一律显式写成只读事务能在读路径上省下相当可观的协调成本。BEGIN AS OF SYSTEM TIME -10s READ ONLY; SELECT count(*) FROM orders; COMMIT;2.3 Follower Read 与 AS OF SYSTEM TIME 的真实适用边界Follower Read是闭时间戳机制的直接产物。既然闭时间戳保证 T 之前的数据在任何副本上都完整那么你就可以让读请求就近落在本地副本上哪怕它不是 Leaseholder。这对跨地域部署的意义极大美国东部的应用读美国东部的副本不用绕到西部的 Leaseholder 那边去。SELECT * FROM t AS OF SYSTEM TIME follower_read_timestamp();follower_read_timestamp()会自动算出一个安全的、已关闭的时间戳大致是当前时间往前推几秒。这个函数是只读扫描。但这里有个坑必须说清楚Follower Read 和AS OF SYSTEM TIME都是时间旅行读天然牺牲实时性。如果你的业务逻辑需要写完立刻读到比如下单后马上查订单状态用 Follower Read 就会读到几秒前的旧数据。我见过一个团队在订单列表页用了 Follower Read 缓解读压力结果用户下单后刷新页面显示订单不存在线上出了不少客服工单。后来改成只对历史订单页用实时订单页老老实实走 Leaseholder 读。下面这张表是我自己总结的几个读档位对照可以直接拿来当选型参考读档位延迟特征数据新鲜度典型场景Leaseholder 读默认单机往返毫秒级最新事务内读、写后读、一致性校验只读事务闭时间戳读单机往返可任意副本秒级延迟报表、批量导出、后台任务Follower Read就近副本跨地域优势明显秒级延迟跨地域读扩展、只读副本服务AS OF SYSTEM TIME 指定时间单机往返指定历史点数据恢复前快照、审计、时间点对账2.4 READ COMMITTED 上线之后读路径变了什么从 v23.2 开始CockroachDB 把READ COMMITTED隔离级别推到了正式可用。这个变化对读路径的影响不小。在 SERIALIZABLE 下读请求一旦撞上不确定区间里的值就可能需要重启事务而 READ COMMITTED 依赖一套**锁表Lock Table**机制来处理读写冲突遇到未提交的 intent 时读会等待或者推动那个事务而不是简单重启自己。实测上的体感是这样的在读多写少、冲突集中的负载下READ COMMITTED 的尾延迟明显更平稳因为不再有大面积的事务重启。代价是它不提供全局可串行化的保证只保证看到已提交数据对一些有强一致性要求的业务逻辑比如计数器、库存扣减需要额外加锁或者用 SERIALIZABLE。我的经验是绝大多数从 MySQL 迁过来的业务应用用 READ COMMITTED 心智负担更小因为它和 MySQL 的默认行为接近但涉及到全局唯一性检查、跨行约束、幂等写入这类场景还是建议显式用 SERIALIZABLE别为了省事把正确性丢掉。3. HLC、时间戳与不确定读分布式一致性的账单都在这儿前面反复提到时间戳这一节把时间戳这套机制单独拿出来说。这是 CockroachDB 最难啃也最容易出问题的部分很多线上疑难杂症最后都追到时钟上。3.1 HLC 混合逻辑时钟是怎么拼出来的分布式系统要有全局一致的顺序物理时钟靠不住NTP 漂移、闰秒、容器迁移都会影响纯逻辑时钟Lamport Clock又和真实时间脱钩导致AS OF SYSTEM TIME这类按真实时间读的功能没法实现。CockroachDB 用的是HLCHybrid Logical Clock把两者捏在一起一个物理时间分量加一个逻辑计数器分量。节点的 HLC 更新规则大致是本地物理时间往前走HLC 就跟着走收到一个比自己更大的 HLC 时把本地 HLC 推到对方的值再加一。这样既保证了全局单调递增又让 HLC 和真实时间的偏差控制在有限范围内。3.2 不确定读窗口到底有多宽怎么算因为 HLC 可能因为互相推送而略微领先真实时间一台节点在读某个键时可能遇到一个时间戳比自己读时间戳大的值但又不能确定这个值是不是在自己读的瞬间才提交的。这段模糊区间就叫不确定区间宽度等于集群配置的--max-offset默认 500ms。当读请求撞上不确定区间内的值时SERIALIZABLE 事务的标准反应是把这个值当作可能没看到重启事务并把自己的时间戳推到那个值的后面或者去推动那个写入事务让它先提交或回滚。所以你会看到restart transaction: TransactionRetryWithProtoRefreshError这类报错本质是并发写冲突的正常表现不一定是 bug。-- 看一下当前的时钟偏移上限配置 SHOW CLUSTER SETTING kv.closed_timestamp.target_duration; SHOW CLUSTER SETTING server.clock.forward_jump_check_enabled;应用层必须做重试这是写 CockroachDB 应用的第一条铁律。官方的各语言驱动都提供了自动重试的封装比如 Go 的crdb.ExecuteTx、Java 的Retry注解、Python 的run_transaction我强烈建议所有写事务都包在里面不要自己手写重试逻辑很容易漏掉错误分类。3.3 时钟偏移超限等于节点自杀这是 CockroachDB 一个让人印象深刻的设计如果节点检测到自己的时钟相对于集群往前跳了超过--max-offset它会主动终止自己。听起来很激进但逻辑是成立的——时钟超限会导致不确定区间失去意义进而可能破坏可串行化保证。与其出错数据不如直接下线。这条设计的现实含义是集群的时钟同步质量直接决定了可用性。NTP 或者云厂商的时钟同步服务必须配置可靠容器环境下还要注意宿主机时钟是否正常。我遇到过一次生产事故某台虚拟机因为宿主迁移导致时钟跳变节点直接退出副本数降级集群进入告警状态。后来我们把--max-offset从默认 500ms 调高到 1s 来容忍更多抖动同时加强了时钟同步的监控——这是一个典型的用一点点一致性容忍度换稳定性的权衡。注意调大--max-offset会同时放大不确定区间导致更多事务重启。这个参数不是越大越好通常云环境保持默认即可只有在时钟质量确实不稳的环境里才考虑微调。3.4 时间戳缓存与锁表读写冲突的两套仲裁时间戳缓存Timestamp Cache是 SERIALIZABLE 下的核心仲裁结构。每个 range 维护一份记录跟踪最近在这个键范围上读过、写过的时间戳。当一个新事务想要在某个时间戳写入时如果发现该时间戳早于缓存里记录的最新读时间戳就会被推到一个更新的时间戳上去——这保证了不会出现写入一个已经被别人读过的时间点这种违反可串行化的行为。锁表Lock Table则是 READ COMMITTED 引入后使用的另一套结构它记录当前活跃的、带锁的 intent读请求遇到锁时可以选择等待或者推动持有者。这套机制是内存态、按 range 维护的所以它的成本主要在内存占用上range 数量多、热点 key 多的场景要留意内存压力。两套机制并存是当前版本的一个现实理解它们的分工对排查为什么这个事务老是重启很有帮助如果重启发生在读阶段通常是时间戳缓存推高了时间戳如果发生在写阶段等待上多半是撞上了别的未提交事务的锁。4. MVCC 版本、GC TTL 和 Range 分裂那些不影响正确性但拖慢读写的事前三节讲的是机制怎么运转这一节讲的是长期运行会积累出什么问题。CockroachDB 在这些方面的行为和单机数据库差别很大很多团队是在生产跑了大半年之后才被这些问题咬到。4.1 MVCC 让读不阻塞写代价是版本堆积CockroachDB 用 MVCC 存数据同一个键在不同时间戳上对应不同版本物理上就是同一前缀的多个 KV键后面附着一个时间戳后缀。这样做的好处是读不阻塞写、写不阻塞读——读拿一个旧时间戳照样能读到自己需要的版本写新版本也不影响老读者。代价是存储里堆着大量历史版本。一个高频更新的行每更新一次就多一个版本一秒钟更新一次的话一天就是 8 万多个版本。这些版本不会立刻被删掉而是要等 GC 周期把它们清理掉。所以你会看到表里只有一百万行磁盘却占了 50GB这种现象这是正常的不是泄漏。4.2 GC TTL、长事务和备份之间的拉扯每个 range 有一个GC TTL配置决定历史版本保留多久。老版本v23.1 之前默认是 25 小时较新版本为了降低存储占用把新集群的默认值调低到了 4 小时左右。这个值可以按表或者按 range 配置ALTER TABLE t CONFIGURE ZONE USING gc.ttlseconds 3600; -- 查一下当前生效的配置 SHOW ZONE CONFIGURATION FROM TABLE t;调小 TTL 能显著降低存储占用和 Compaction 压力但有两个硬约束必须满足AS OF SYSTEM TIME的查询范围不能超过 GC TTL。如果你经常要回看 12 小时前的数据TTL 就不能低于 12 小时否则查询会直接报错。备份必须能在 TTL 窗口内完成并保留依赖。增量备份依赖的版本如果被 GC 掉备份链就断了。CockroachDB 有备份保护机制会主动阻止 GC 清理备份需要的版本但备份周期和 TTL 的关系还是要自己算清楚。提示长事务是 GC 的另一个绊脚石。一个跑了几小时的事务会把它读过的版本一直占着导致 GC 无法推进存储持续膨胀。实践中我给所有应用的transaction_timeout和idle_in_transaction_session_timeout都设了明确上限宁可让慢事务失败重试也不要让它悄悄拖垮整个集群的存储。4.3 Range 分裂与租约转移的连锁反应Range 会自动分裂写入量大的 range 到达 512MiB 上限就一分为二让整个集群的负载重新平衡。分裂本身很轻量但分裂的瞬间会短暂阻塞该 range 的读写因为要更新元数据 range 的记录。高峰期大量分裂会造成明显的延迟毛刺。更麻烦的是热点 range 的反复分裂如果一个自增主键把所有写入都压到最后一个 range它会不断分裂每个新 range 又立刻成为热点分裂、搬迁、再分裂形成恶性循环。解决办法要么换散列主键要么手动SPLIT AT加SCATTER把负载预先打散ALTER TABLE t SPLIT AT SELECT gen_random_uuid() FROM generate_series(1, 16); ALTER TABLE t SCATTER;租约转移也会带来类似的毛刺。节点的负载均衡、重启、下线都会触发租约迁移迁移期间对应的 range 短暂不可读。这是设计使然不是异常。如果你的 P99 毛刺出现在节点运维时间点附近基本可以确认是这个原因。5. 真要调优读写我通常按这个顺序动手前面讲完机制这一节聊操作。大部分读写性能问题其实不需要读源码按一个固定的顺序看指标、缩小范围绝大多数能在半小时内定位到方向。5.1 指标从哪几个入口切进去我一般从三个入口切第一层SQL 层延迟。sql.service.latency是端到端的sql.exec.latency是执行层的。两个的差值大致就是网络和排队的时间。如果端到端延迟高但执行延迟低问题在客户端或网络别往数据库里查。第二层KV 层延迟。kv.propose.wait反映请求在 Raft 提案前的排队时间这个值高说明 Leaseholder 忙不过来或者 Lease 不在本节点。raft.process.commandcommit.latency反映 Raft 提交本身的耗时这个值高基本等于节点间网络慢或者磁盘 fsync 慢。第三层存储层。L0 文件数量、Compaction 排队长度、WAL fsync 延迟。这三个指标能解释大部分写入突然变慢的现象。-- 快速看一批关键指标 SELECT name, value FROM crdb_internal.node_metrics WHERE name IN ( sql.service.latency-p99, kv.propose.wait-p99, raft.process.commandcommit.latency-p99, storage.disk-slow );5.2 三类典型读写问题的排查链路症状一写入 P99 突然翻倍均值没变。先看是不是发生了 range 分裂或者租约转移对照运维时间点再看 L0 文件数量是否陡增。如果是分裂引起属于正常波动观察即可如果是 Compaction 跟不上考虑降低写入并发或者扩容。症状二读延迟稳定但偏高比如恒定 20ms。这通常是读写流量路由问题——请求可能跨了地域或者读到了远端 Leaseholder。用SHOW RANGES确认 Leaseholder 位置检查应用连接串里配置的节点是不是都在同一区域。症状三事务频繁重启报错日志刷屏。先确认隔离级别。READ COMMITTED 下大量重启说明锁竞争严重SERIALIZABLE 下说明时间戳冲突多。前者考虑缩小事务范围、调整业务写顺序后者考虑把只读部分拆到独立只读事务里。5.3 读写路径关键参数速查下面这张表是我自己常用的出事第一个想到改哪个参数的对照注意默认值随版本变化动手前先SHOW CLUSTER SETTING确认当前值参数作用范围默认值大致什么时候动它kv.range_max_bytes单 range 大小512MiB大表热点明显、分裂过于频繁时考虑调大kv.closed_timestamp.target_duration闭时间戳推进节奏3s调大则 Follower Read 延迟变大、协调更省一般不调gc.ttlseconds历史版本保留老版本 25h / 新版本 4h存储压力大想调小有历史查询需求要调大--max-offset时钟偏移容忍500ms时钟质量不稳时谨慎调大同时会放大不确定区间transaction_timeout事务超时无限制生产环境建议必设防止长事务阻塞 GCkv.snapshot_rebalance.max_rate副本搬迁限速视版本而定搬迁抢占带宽影响业务时下调真正调优的时候我会把改参数排在最后。因为 CockroachDB 的绝大多数性能问题根因都在数据模型和访问模式上而不是参数没调好。主键选得散不散、索引建得多不多、事务划得宽不宽这三点决定了性能上限参数只是在既定上限内做微调。我见过不少团队一上来猛调参数折腾两周没效果最后换了个随机主键写入吞吐翻了三倍。最后分享一个我自己常用的小技巧在压测环境里先固定所有参数不调用真实数据模型跑一遍把写入延迟和 range 分布画出来再针对性优化。这样能很清楚地看出瓶颈到底是分布式协议的成本还是数据模型不合理比一上来就翻参数文档高效得多。
返回列表