土法炼钢 · 系统与基础设施

【Ceph RADOS】客户端写路径:从 librados 到副本应答与 stale read 边界

文章导航

分类入口
storagedistributed
标签入口
#ceph#rados#librados#objecter#mosdop#primarylogpg#stale-read#replication#squid#v19.2.5

目录

前五篇把集群的每一层分别拆开:MON 维护 epoch、OSD 收 Op、CRUSH 算位置、PG 管一致性。本篇把它们串成一条线:一次 rados put 从 librados API 调用到收到写成功回复,经过哪些步骤,每一步的失败模式是什么,以及什么情况下客户端读到的是旧数据(stale read)。

本文是「Ceph RADOS 存储内核」系列第 6 篇(共 18 篇)。→ 系列目录

篇目 核心内容
第 1 篇 · RADOS 全景 缺口、五轴坐标系、18 篇路线
第 2 篇 · MON/MGR 与 Map quorum、OSDMap/PGMap、epoch 传播
第 3 篇 · OSD 与 Messenger daemon、msgr2、Op 进入 OSD
第 4 篇 · CRUSH 加深 rule/bucket/weight 机制钉
第 5 篇 · PG 与 Peering PrimaryLogPG、PG log、状态机
第 6 篇 · 客户端写路径 librados → 副本应答、stale read 边界
第 7 篇 · BlueStore 架构 BlockDevice / BlueFS / RocksDB / Allocator

版本锚定:Ceph Squid v19.2.5。源码路径以 ceph/ceph tag v19.2.5 为准。


一、librados 与 Objecter:客户端路由层

librados 的角色

librados 是 RADOS 的 C/C++ 客户端库,对外暴露 rados_t(cluster handle)和 rados_ioctx_t(pool 上下文)两层 API。在内部,librados 把所有操作委托给 Objectersrc/osdc/Objecter.h @ v19.2.5)——这个类也被 cephfs(MDS 的 objecter)、rbd(librbd)和 rgw 共用。

Objecter 的职责:

  1. 维护 OSDMap 缓存:从 MON 订阅 OSDMap,本地缓存当前 epoch。
  2. 计算对象目标:给定 pool + object name,通过 CRUSH 计算出 Primary OSD。
  3. 发送并重试 Op:把操作封装成 MOSDOp,发送到 Primary;收到 epoch 不匹配的错误时自动刷新 map 并重试。
  4. 管理 Op 生命周期:超时、重连、Messenger 层断连后的重发。

对象寻址步骤

flowchart LR
  A["object name hash"] --> B["hash % pg_num = pgid"]
  B --> C["OSDMap: CRUSH(pgid) = acting set"]
  C --> D["acting[0] = Primary OSD"]
  D --> E["send MOSDOp to Primary"]

关键函数:Objecter::_calc_target()src/osdc/Objecter.cc @ v19.2.5):

  1. 取对象名的 hash(ceph_str_hash_rjenkins(),与 CRUSH 使用同一哈希族)。
  2. hash_to_pg() 把 hash 映射到 pg_t(考虑 pg_numpgp_num)。
  3. 用 OSDMap 的 pg_to_acting_osds() 接口通过 CRUSH 计算 acting set。
  4. acting[0] 即为 Primary;若 Primary 在 osd_primary_affinity 影响下有偏移,则查 acting_primary 字段。

二、MOSDOp 的构造与字段语义

MOSDOpsrc/messages/MOSDOp.h @ v19.2.5)是客户端写请求的消息类型,主要字段:

字段 含义
client_inc 客户端重连计数器,用于 Primary 去重(一次连接断开重连后递增)
map_epoch 客户端发送时的 OSDMap epoch
flags RADOS Op 标志位(CEPH_OSD_FLAG_WRITECEPH_OSD_FLAG_ACKCEPH_OSD_FLAG_ONDISK 等)
oid 目标对象名(含 namespace、key)
pg 目标 PG(spg_t
snapc Snapshot context(写 clone 时携带)
ops OSDOp[] 向量,每个 OSDOp 含操作码(CEPH_OSD_OP_WRITECEPH_OSD_OP_READ 等)、offset、length
reqid 请求唯一 ID(osd_reqid_t = {client_addr, client_inc, tid}),用于幂等去重

flagsCEPH_OSD_FLAG_ONDISK 决定客户端等待 Primary ack 时,是等「写到副本内存」(ack on journal/WAL 到达)还是「写到副本磁盘」。Squid 默认行为是等全副本 journal(WAL)持久化后才回复客户端,而非等 fsync 完成——这是 BlueStore deferred write 能在不降低 durability 保证的前提下提升吞吐的基础。


三、Primary 的 do_op() 执行

第 3 篇 可知,MOSDOpShardedOpWQ 进入 PrimaryLogPG::do_request() / do_op()PrimaryLogPG.cc @ v19.2.5)。简化步骤:

  1. 检查 pool、snap、对象是否属于本 PG;PG 必须可服务(否则进 waiting_for_active 等队列)。
  2. 加载 ObjectContext(onode 等元数据)。
  3. 处理 snapshot/clone(细节见 第 12 篇)。
  4. prepare_transaction() 生成 ObjectStore::Transaction(数据写 + PG log 追加等)。
  5. issue_repop() 向 acting 副本发 MOSDRepOp
  6. 收齐策略要求的副本应答后,提交并回复客户端。

一次写的 txn 典型包含数据写、可选 snap 元数据、以及把 pg_log_entry_t 记入元数据存储(BlueStore 上经 RocksDB/BlueFS 路径)。本地分配器与 deferred 切换留给第 7–8 篇;本篇只强调:版本号与是否提交由 Primary 决定,副本不能改写 eversion

若 PG 处于 degraded 但仍 Active,写仍走当前 acting;min_size 不满足则根本进不了可写 Active(第 5 篇)。客户端侧看到的「卡住」要先区分:是 peering 未完成、是副本 ACK 慢,还是读租约 LAGGY——三者日志与状态字不同。


四、副本写:MOSDRepOp 与应答链

Primary → Replica

Primary 向 acting set 中每个副本发 MOSDRepOpsrc/messages/MOSDRepOp.h @ v19.2.5):数据载荷加上 Primary 已定好的 pg_log_entry_t。副本不能改版本号,只能接受或拒绝(拒绝可触发重新 peering)。这落实 PDSW 2007 的「Primary 为单点序列化者」。

Replica 处理与 ACK 含义

副本在 epoch 匹配时应用 ObjectStore::Transaction,再回 MOSDRepOpReply。回复携带的 flag(如 ACK / ONDISK)表示持久化进度到了哪一档;客户端侧最终看到的成功,取决于请求 flags 与池策略要求等到哪一档。Squid 上常见路径是:副本侧先把更新记到可崩溃恢复的日志/WAL 语义路径,再让 Primary 向客户端确认——具体与 BlueStore deferred 的交界在第 7–8 篇,本篇不展开分配器。

Primary 收齐后

RepGatherReplicatedBackend)跟踪应答。达到策略要求的副本集合后,Primary 提交本地事务、更新 pg_info.last_update、向客户端发 MOSDOpReply

sequenceDiagram
  participant CLI as librados Client
  participant PRI as Primary OSD
  participant R1 as Replica 1
  participant R2 as Replica 2

  CLI->>PRI: MOSDOp (write, epoch N)
  PRI->>R1: MOSDRepOp (data + pg_log_entry)
  PRI->>R2: MOSDRepOp (data + pg_log_entry)
  PRI->>PRI: local ObjectStore txn
  R1-->>PRI: MOSDRepOpReply
  R2-->>PRI: MOSDRepOpReply
  PRI-->>CLI: MOSDOpReply

失败模式:某一副本慢 → 整次写尾延迟被最慢副本拖住(Primary 序列化的代价);副本 epoch 落后 → 忽略/拒绝,Primary 侧可能进入 peering。这与「读租约 LAGGY」不同层:写卡住看副本 ACK,读卡住看 readable_until


五、可读性边界:Preventing Stale Reads 与 readable_until

写路径在 acting set 同步提交后才 ACK,限制了写侧不一致。读默认只打 Primary,客户端用本地 OSDMap 选目标。多数情况下:map 正确,或旧 Primary 已知自己不再是 Primary,会塞给客户端一张更新的 map。官方要堵住的是另一类故障(docs.ceph.com/en/squid/dev/osd_internals/stale_read/):

某个 OSD 被隔开、尚未学到 map 更新,却继续为同样落后的客户端服务读;与此同时,新 Primary 已经可以接受写。

这才是「stale read」威胁模型——不是「RADOS 主动返回旧副本值」,而是被围栏的旧 Primary 在新区已经前进后仍提供读

读租约:readable_until / readable_until_ub

每个 pool 可设 read_lease_interval;默认取

\[ \mathrm{read\_lease\_interval} \approx \texttt{osd\_pool\_default\_read\_lease\_ratio}\times \texttt{osd\_heartbeat\_grace} \]

其中 ratio 默认 0.8。意图:在 MON 把失败 OSD 标 down 之前,读租约通常已经过期。因此调大 osd_heartbeat_grace 会同时拉长「误判 down」的耐心和「旧 Primary 仍可能可读」的窗口——与 Stretch / 跨 AZ 部署直接相关。

Primary 与副本都维护:

字段 含义
readable_until 本 OSD 允许服务读的截止时间(租约)
readable_until_ub 对 acting set 内任意 OSD 的 readable_until 的上界

Primary 发 pg_lease_t 抬高副本侧上界;收齐 pg_lease_ack_t 后,再抬高自己的 readable_until 并再次分享。不变量:

\[ \forall\, o\in\mathrm{acting}:\quad o.\mathrm{readable\_until}\le o'.\mathrm{readable\_until\_ub}\quad(\forall o') \]

全程用单调时钟,避免墙上时钟跳变;peer 估计本地时钟偏差的上下界,把 Primary 时间戳翻译成各自本地截止时刻。

Interval 切换:prior readable_until_ub、LAGGY、WAIT

Interval 变更时,需要 prior interval 上「谁还可能在服务读」的上界。各 OSD 把 readable_until_ub(作为一段剩余时长)放进 pg_history_t 参与 peering;因 peering 消息延迟,上界会略往前滑——对上界而言是安全方向。

状态 触发 对客户端
LAGGY Active 期间租约过期仍收到请求 读/相关请求阻塞,租约续上后重入队
WAIT Peering 已完成,但 prior interval OSD 仍可能可读 阻塞客户端请求直至等待足够或提前退出;recovery 可继续(用户可见内容不变)

Dead OSDs 提前结束 WAIT:若能推断 prior OSD 已崩溃(如 ECONNREFUSED),或该 OSD 已知自己被标 down(dead_epoch 等),则可提前离开 WAIT,而不用空等到租约自然过期。

sequenceDiagram
  participant P as Primary
  participant R as Replica
  participant C as Client
  P->>R: pg_lease_t (raise readable_until_ub)
  R-->>P: pg_lease_ack_t
  P->>R: pg_lease_t (raise readable_until)
  Note over P,R: readable_until <= any readable_until_ub
  C->>P: MOSDOp READ
  alt lease valid
    P-->>C: data
  else readable_until expired
    P->>P: enter LAGGY, block op
  end

与 epoch 追赶、peering 的分工

机制 解决什么
客户端 map / Objecter 重试 客户端或 OSD 视野不一致时的路由纠错
readable_until 租约 阻止「旧 Primary + 旧客户端」在新区已写之后继续读
peering WAIT 新区已 Active,但仍须等旧区读租约失效
waiting_for_active 本 interval 尚未 Active(第 5 篇)

因此「写成功后 Primary-routed 读立即可见」在同一 Active interval、租约有效时成立;跨 Primary 切换时,正确性靠租约与 WAIT,而不是「返回旧值」或单纯依赖 osd_op_timeout

reqid 去重边界(写侧)

MOSDOp.reqid(client + client_inc + tid)记在 pg_log_entry_t 上做 at-most-once。同一 client_inc 内重发可直接返回旧结果;client_inc 递增后旧 reqid 失效——网络分区下重连后不应盲目重放未确认写。


六、客户端重试与 epoch 追赶

错误 / 现象 原因 Objecter 动作
附带新 MOSDMap 的重试 客户端 map 落后或 acting 变化 合并 map,_calc_target() 后重发
OSD 尚未追上 map 客户端相对新 等待 / 订阅后重发
连接超时 Primary 无响应 osd_op_timeout / rados_osd_op_timeout 约束后向 MON 要 map
ENOENT 等应用语义错误 对象状态不符 上报应用,不盲目重试

重试中 Op 由 Objecter 持有,不丢请求;aio_* 回调看到的是最终结果。默认 osd_op_timeout = 0(无超时)时,宕机 Primary 可能导致长时间挂起——生产宜设有限超时,并与 Messenger keepalive 区分开。

跨 Primary 切换时还可能先卡在第 5 篇的 peering,或本节的 LAGGY/WAIT(读租约),这与「单纯 epoch 数字不等」不是同一层。把三类阻塞画在同一时间线上,才能解释「集群看起来健康但读请求仍挂起」的现场现象。


七、小结

  1. 客户端路由本地完成:Objecter 用缓存 OSDMap 算 Primary;map 追赶对应用透明,表现为延迟毛刺。
  2. reqid 幂等边界:同一 client_inc 内可去重;重连递增后旧 tid 失效。
  3. Primary 单点序列化:版本号只由 Primary 分配;副本只接受或拒绝。
  4. 写完成语义:默认等副本侧持久化路径(WAL/journal 语义)达到策略要求后 ACK;与 BlueStore deferred 的配合见第 7–8 篇。
  5. stale read 靠读租约堵住:威胁是隔开的旧 Primary 继续服务落后客户端;readable_until / LAGGY / WAIT 是官方机制(stale_read/)。
  6. 延迟抖动分层:epoch 追赶 / peering·租约 / BlueStore——三轴排查路径不同。

八、谱系、争论与开放问题

谱系

客户端写路径的核心设计(Objecter → MOSDOp → Primary → RepOp → Reply)在 Sage Weil 博士论文(2007)§6 与 RADOS 论文(PDSW 2007)中已成形:Primary 而非 Monitor 控制副本写;副本不能修改 PG log 版本。Squid 在此之上叠加的关键工程层是 Preventing Stale Reads 的读租约——把「map 最终会纠正路由」不够覆盖的围栏场景,收成可配置的时间界。

读租约与 MON 的 MMonLease、OSD 心跳 grace 处在同一「用时间换安全」家族,但作用点不同:MON 租约服务管理面读;readable_until 服务数据面读围栏。调参时不要只改其中一个却假设另一个自动正确。

争论:强一致 Primary-only 读 vs 副本读

A 侧(默认 Primary-only):与 Preventing Stale Reads 的租约模型一致,RBD/CephFS 依赖强一致。
B 侧(BALANCE_READS / LOCALIZE_READS:分散读热点,但一致性弱化;peering/租约窗口下的语义需应用自行承担。官方 stale-read 文档针对的是默认 Primary 读路径下的围栏问题,开启副本读等于换威胁模型。

实践中 RBD 默认不走副本读;若在跨 AZ 场景用 LOCALIZE_READS 换延迟,必须单独回答「可接受多旧、租约与 map 如何配合」——这不是开关级优化,而是一致性模型变更。

开放问题

  1. 跨地域 Primary 与租约时长:Stretch / 多 AZ 下写 RTT 与 read_lease_interval(随 osd_heartbeat_grace)耦合——grace 调大减误判,却拉长 WAIT/LAGGY 窗口。可读入口:docs.ceph.com/en/squid/rados/operations/stretch-mode/stale_read/
  2. 大对象写与 BlueStore 尾延迟:覆盖写撞上 RocksDB compaction 时,租约与 epoch 都正常,延迟却来自本地存储轴——第 7–9 篇拆。可检验问题:在排除 LAGGY/WAIT 与 map 追赶后,延迟尖刺是否仍与 OSD 上 RocksDB stall 计数相关?

九、参考资料

规范 / 官方文档(A)

源码(A)

论文(A)

站内对照

实验台账


上一篇:PG 与 Peering · 下一篇:BlueStore 架构

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。


By .