MQ 生产、追加、复制与事务可见性
本页固定 Kafka 3.7.1,沿一条订单事件回答“哪个确认已经发生、故障后还能证明什么”。消费进度拆到消费位点与重平衡,网络补充拆到TCP。固定版本用于复现,不代表生产推荐版本。
七个成功点,七种承诺
订单数据库提交、producer 入缓冲、broker 追加、复制边界推进、事务可见、消费者业务提交、消费位点提交,是不同的成功点。send() 返回 Future 通常只表示发送已被安排,序列化、元数据或缓冲不足也可能在调用线程失败。Future 成功表示获得对应 Produce 确认,不能证明消费者已经完成业务。若订单提交后把发送超时当作“订单未创建”,用户重试便可能重复创建订单。
未发送成功、已经追加但响应丢失、客户端接收响应前断开,调用者可能看到相同超时。网络上没有“没收到响应,所以没发生”的可靠推理。发送方需要稳定业务身份、有限重试、结果未知状态和查证路径。至少一次描述投递,幂等描述业务效果,二者不能相互替代。
分区是追加、复制和顺序单位。offset 是分区内日志位置,不是全 topic 全局时间;跨分区无天然总序。批次、压缩、linger 和并发共同决定效率,热点 key 会限制在单分区。评估容量应同时给每秒字节数、消息大小分布、压缩率及保留周期,只有“每秒十万条”不能决定磁盘和网络预算。
订单事件 e7 的证据链
| 观察时刻 | 已知事实 | 此时崩溃后的恢复责任 |
|---|---|---|
| 订单事务已提交,事件尚在 Outbox | 订单与发送意图存在 | relay 从持久意图继续发送 |
send 已返回,Future 未完成 | 客户端可能仍在等批或网络 | 不能重建一个新业务意图;查原发送及本地意图 |
| leader 追加 offset104,HW103 | 日志中存在,尚不满足该复制边界 | 保持相同 producer 批次的协议重试;业务身份不变 |
| HW105,最早未决事务104 | 已复制,read_committed 仍可能不可见 | 等事务决议及 marker 稳定,不能跳过未决事务 |
| 消费者数据库已提交,offset 未确认 | 业务效果存在,恢复仍可能重投 | Inbox 与效果同事务裁决重复 |
这张表不是全局原子事务。每列“事实”都要求来自相应权威的证据,不能拿 producer Future 成功替代数据库终态,也不能拿 consumer lag 为零替代外部付款成功。
从 Java send 追到本地日志
客户端链为 KafkaProducer.send → doSend → RecordAccumulator.append;Sender 线程取批次,经 NetworkClient 发 ProduceRequest。doSend 等元数据、序列化、确定分区,入缓冲后按条件唤醒 Sender。调用返回不等于已经发出网络包,缓冲等待和批次等待都是真实延迟。max.block.ms 主要限制元数据与缓冲分配等待,不能当整个异步交付 deadline;delivery.timeout.ms 限制交付窗口,request.timeout.ms 是请求等待尺度。来源:KafkaProducer、Sender。
broker 链为 KafkaApis.handleProduceRequest → ReplicaManager.appendRecords → appendToLocalLog → Partition.appendRecordsToLeader → UnifiedLog.appendAsLeader。鉴权、分区、事务和记录格式校验均可能拒绝请求;追加阶段验证 batch、分配 offset、更新 producer 状态并写入日志。后续进入 LocalLog/LogSegment 的文件追加,page cache 写入与强制刷盘不能混为一谈。来源:KafkaApis、ReplicaManager、UnifiedLog。
下面是 Partition.appendRecordsToLeader 的精简教学改写,省略参数、锁和指标:
val minIsr = effectiveMinIsr(leaderLog)
if (partitionState.isr.size < minIsr && requiredAcks == -1)
throw new NotEnoughReplicasException(...)
val info = leaderLog.appendAsLeader(records, leaderEpoch, origin, ...)
maybeIncrementLeaderHW(leaderLog)副本检查在追加前,且这一拒绝条件针对 acks=-1,即 all。3.7.1 的 effectiveMinIsr 取配置值与副本数量较小值,因此不要用“把 min ISR 设得大于副本总数”代替真实故障实验。追加时的锁保护不代表等待复制期间 ISR 永远不变。来源:Partition 3.7.1。
LEO、ISR、HW 与确认等待
LEO 是某副本下次追加位置,HW 是复制提交边界。例如记录40追加后 LEO=41,HW=41才能把40作为普通可读记录。acks=0不等待broker Produce响应;acks=1等leader本地追加;acks=all等对应requiredOffset达到复制确认条件。all不是“随便两个副本”,也不是“所有被分配副本永远必须在线”。
Follower通过Fetch拉取记录,随后报告自身位置,leader据此更新复制进度。ISR是满足同步条件的集合,不等于全部副本。落后副本可能移出,追上再加入。maybeIncrementLeaderHW 结合副本状态计算推进边界,3.7.1在under-min-ISR时直接不推进HW。正在扩展ISR时还需看maximalIsr,包含提交过程中的扩展成员;稳定状态可用“同步集合最小复制位置”作近似,竞争路径不能仅凭控制台一行ISR下结论。
ReplicaManager为复制等待创建DelayedProduce;tryComplete逐分区调用checkEnoughReplicasReachOffset(requiredOffset),直到满足、报错或超时。requiredOffset是本次最后offset加一,HW至少到这个边界才有成功基础。来源:DelayedProduce、Partition。
NOT_ENOUGH_REPLICAS可能来自追加前ISR不足;NOT_ENOUGH_REPLICAS_AFTER_APPEND来自已经追加、等待期间副本条件恶化的分支。第二种错误不证明记录不存在,即使请求超时日志中仍可能有记录。同一producer批次重试可由幂等序列保护,跨producer业务重发需要业务幂等。排障时要同时保存错误码、topic-partition、producer身份、批次序列和时间窗,只搜到一行异常不能裁决是否已产生业务效果。
三副本、min ISR二、acks all允许失去一个同步副本后继续写,再失去一个则优先拒写。为了恢复可用性下调min ISR意味着改变故障风险,须明确持续时长与恢复责任。不干净选主可能用陈旧副本换可用性,不能同时无条件承诺所有已确认记录不丢。复制确认也不是每副本对每条记录同步fsync,故障模型必须区分进程、主机、磁盘及多个副本同时失效。
原码:等的是分区确认条件,不是客户端成功幻觉
原码连续摘录(仅统一公共缩进,截取方法局部):DelayedProduce.scala,3.7.1,L103–L119。
partition.checkEnoughReplicasReachOffset(status.requiredOffset)
}
// Case B || C.1 || C.2
if (error != Errors.NONE || hasEnough) {
status.acksPending = false
status.responseStatus.error = error
}
}
}
// check if every partition has satisfied at least one of case A, B or C
if (!produceMetadata.produceStatus.values.exists(_.acksPending))
forceComplete()
else
false
}partition.checkEnoughReplicasReachOffset检查目标requiredOffset是否已满足复制条件;有错误或足够副本到达时清除该分区acksPending。所有分区都不再pending才forceComplete,否则继续等待。完成可能携带错误,不是forceComplete就业务成功;写入已发生但响应超时,也不能由这段等待逻辑撤销。
原码:HW 达标后仍可能返回追加后副本不足
固定 apache/kafka / 3.7.1,Partition.scala L1124–L1135。以下连续原码只统一公共缩进,没有改写分支。
val minIsr = effectiveMinIsr(leaderLog)
if (leaderLog.highWatermark >= requiredOffset) {
/*
* The topic may be configured not to accept messages if there are not enough replicas in ISR
* in this scenario the request was already appended locally and then added to the purgatory before the ISR was shrunk
*/
if (minIsr <= curMaximalIsr.size)
(true, Errors.NONE)
else
(true, Errors.NOT_ENOUGH_REPLICAS_AFTER_APPEND)
} else
(false, Errors.NONE)这支先比较 leaderLog.highWatermark >= requiredOffset;达到后再检查 curMaximalIsr 是否满足有效 min ISR。返回 (true, error) 的 true 表示等待可以结束,error 仍可能是 NOT_ENOUGH_REPLICAS_AFTER_APPEND。如果 HW 未达到,当前分支是继续等待;不能把任意 ISR 收缩都画成马上返回同一种错误。动态 ISR 扩张中的 maximalIsr、leader epoch 与日志恢复不在后文稳定快照模型内。
HW 与 LSO:已复制仍可能不可见
事务消费者还受Last Stable Offset限制。UnifiedLog.lastStableOffset取HW与最早未稳定事务起点的较小值;无未稳定事务时等于HW。read_uncommitted仍受HW限制,不是读到leader LEO;read_committed在LSO前读取并过滤中止事务。控制批次占用offset但不作为业务记录返回,因此offset间隙不必然代表丢数据。源码:UnifiedLog 的 lastStableOffset。
输入HW=110、事务T起点104且未结束、非事务记录107,则LSO=104,committed消费者也不能越过T先返回107,否则T稍后提交将破坏分区可见顺序。事务commit/abort marker及其复制状态稳定后边界才能前进。abort是逻辑过滤T,不是马上删除整个日志段。排查“发成功但看不到”时应比较HW、LSO、最早打开事务及LastStableOffsetLag;只看leader磁盘文件存在不能证明消费者应当可读。
事务协调器以transactional.id管理事务任期。TransactionCoordinator.handleEndTransaction从Ongoing进入PrepareCommit/PrepareAbort,持久化决议并向参与分区发marker,再完成状态迁移。协调器恢复需要恢复决议和marker发送,不能靠重新消费绕过未决事务。来源:TransactionCoordinator。事务输出与消费位点的组合见下文Kafka 事务范围,数据库发布意图见Outbox。
原码:LSO 对 HW 再加一道边界
固定 apache/kafka / 3.7.1,UnifiedLog.scala L425–L430。以下连续原码只统一公共缩进,没有改写分支。
def lastStableOffset: Long = {
firstUnstableOffsetMetadata match {
case Some(offsetMetadata) if offsetMetadata.messageOffset < highWatermark => offsetMetadata.messageOffset
case _ => highWatermark
}
}有未稳定起点且它小于 HW 才返回该起点,否则返回 HW;因此事务已经追加、复制完成、对 committed 读者可见仍是三个观察。这个方法只求边界,Fetch 读取与中止事务过滤还属于后续读取路径。删掉这道限制,会让后写的非事务记录越过未决事务,破坏约定的可见顺序。
幂等producer保护什么
3.7.1客户端通过TransactionManager管理producerId/epoch和分区sequence,Sender发送带身份序列的batch。broker追加时由UnifiedLog分析producer状态,ProducerAppendInfo.checkProducerEpoch/checkSequence校验批次;重复batch可以依据已有批次元数据识别并返回原位置,乱序或旧epoch则进入错误路径。来源:TransactionManager、ProducerAppendInfo、UnifiedLog。
下面是教学伪代码,不包含真实批次缓存和epoch恢复全部分支:
if producerEpoch < storedEpoch: reject old producer
if batch matches a remembered batch: return previous append metadata
if same epoch and firstSequence != expectedNext: reject out-of-order
append batch; update producer sequence state不要把sequence误解成全topic业务序号。它按producer与分区维护;另一个producer发同样JSON不会被当作重复。进程重建、身份变化、应用主动重新构造新消息、外部数据库副作用均超出这种去重范围。transactional.id提供稳定事务身份与fencing,也不自动识别任意业务重复意图。
3.7的幂等条件涉及acks all、retries大于零、max.in.flight.requests.per.connection不超过5;显式不兼容配置可能导致配置失败,不能边配置acks1边声称启用同等幂等保证。无幂等且多在途请求时,前批失败重试、后批先成功可能改变追加顺序;幂等机制在支持配置下通过序列检查和客户端重试管理维持分区顺序。来源:3.7 producer配置、Sender重试分支。
原码:producer去重依赖epoch与sequence身份
原码连续摘录(仅统一公共缩进,截取方法局部):ProducerAppendInfo.java,3.7.1,L111–L136。
private void checkSequence(short producerEpoch, int appendFirstSeq, long offset) {
if (verificationStateEntry != null && appendFirstSeq > verificationStateEntry.lowestSequence()) {
throw new OutOfOrderSequenceException("Out of order sequence number for producer " + producerId + " at " +
"offset " + offset + " in partition " + topicPartition + ": " + appendFirstSeq +
" (incoming seq. number), " + verificationStateEntry.lowestSequence() + " (earliest seen sequence)");
}
if (producerEpoch != updatedEntry.producerEpoch()) {
if (appendFirstSeq != 0) {
if (updatedEntry.producerEpoch() != RecordBatch.NO_PRODUCER_EPOCH) {
throw new OutOfOrderSequenceException("Invalid sequence number for new epoch of producer " + producerId +
"at offset " + offset + " in partition " + topicPartition + ": " + producerEpoch + " (request epoch), "
+ appendFirstSeq + " (seq. number), " + updatedEntry.producerEpoch() + " (current producer epoch)");
}
}
} else {
int currentLastSeq;
if (!updatedEntry.isEmpty())
currentLastSeq = updatedEntry.lastSeq();
else if (producerEpoch == currentEntry.producerEpoch())
currentLastSeq = currentEntry.lastSeq();
else
currentLastSeq = RecordBatch.NO_SEQUENCE;
// If there is no current producer epoch (possibly because all producer records have been deleted due to
// retention or the DeleteRecords API) accept writes with any sequence number
if (!(currentEntry.producerEpoch() == RecordBatch.NO_PRODUCER_EPOCH || inSequence(currentLastSeq, appendFirstSeq))) {checkSequence先处理验证状态,再分新epoch与同epoch;新epoch的首序号不合法会OutOfOrderSequenceException,同epoch再与已有末序号衔接。这里检查的是分区日志写入身份,不知道你的order_id,更不会替外部支付建立业务唯一约束。重启后使用新身份重新发送也不能自动沿用旧业务去重范围。
日志顺序、执行顺序、业务顺序
Kafka只给分区内追加顺序;按key分区是实现聚合局部顺序的手段,业务上还需保证同一key来源、映射与版本。多个producer并发发送同一订单,broker看到的先后未必符合上游业务提交先后。用数据库聚合version和Outbox共同提交,可把业务先后显式编码为aggregate_id, version。
消费者维护lastVersion,收到v=last+1则应用;v小于等于last属于重复或过期,要核对事件身份;v大于last+1说明缺口,进入有限乱序缓冲/补拉,不能直接丢弃。下面为教学SQL,依赖同事务Inbox及失败分流:
UPDATE order_projection
SET state='PAID', version=12
WHERE order_id=7 AND version=11;影响行数零不等于“一定重复”,也可能是缺失前驱或非法状态。应读当前版本区分旧事件、未来事件、冲突事件。若后续事件是独立可交换加法,可以采用去重后累加,无须阻塞整条链;若是支付后发货,跳过支付状态直接发货会破坏不变量。顺序要求应从业务条件推导,不是看到MQ就要求全局FIFO。
重试到旁路topic会让同key后续记录先到主消费者,破坏原局部执行顺序。选择原分区阻塞、按key隔离、聚合版本拒绝乱序或可交换操作,每种都转移不同成本。增加分区会改变key映射,需要迁移屏障;DLQ回放也要经过原幂等与版本逻辑,不能走一个“管理员专用直接更新”接口。
顺序纸面输入:本地version10,交付12、11、11、13。正确路径先缓冲12,应用11后补12,第二个11去重,再应用13,最终version13。给12设置10秒等待期限后若11仍缺失,应隔离并触发补拉/对账,而非永久占内存。每个缓冲记录应有大小、年龄和恢复责任人,运行超时必须可观察。
组合 Inbox 时还要核对未应用事件的持久状态。收到未来版本 12、缺少 11 时,不得先把 Inbox 记为 SUCCEEDED 再只放进内存缓冲;进程退出后,重投 12 会被去重却从未应用。可以回滚本次事务并保留原消息可重放,也可以在同一事务中将载荷和 DEFERRED 状态持久化,由明确的恢复者在前驱满足后原子推进效果与终态。前者还需配合消费位点安全前沿,不能一边回滚业务、一边提交越过该消息的 offset。
Kafka 事务的源码与EOS范围
Kafka 3.7.1事务producer调用initTransactions → beginTransaction → send → sendOffsetsToTransaction → commitTransaction。消费组关闭auto commit;发送位点取已完成输入的下一offset,并携带group metadata。多输出分区和输入位点可以属于同一Kafka事务,外部数据库、邮件、支付接口不在其中。
客户端由TransactionManager维护producerId/epoch和事务状态;broker的TransactionCoordinator.handleEndTransaction验证身份及任期,在Ongoing上准备PrepareCommit或PrepareAbort,持久化到事务状态日志,随后发marker推进完成。以下为精简教学改写:
validate transactionalId, producerId, producerEpoch
if pending transition: return CONCURRENT_TRANSACTIONS
if state == Ongoing:
prepare = commit ? PrepareCommit : PrepareAbort
append transaction state
send commit/abort markers to participant partitions
complete state after marker workflow源码实际还包含重试完成、协调器任期变化、fencing和非法状态分支。旧producer恢复不能与新任期任意并行提交;epoch是事务身份安全的一部分。来源:TransactionManager、TransactionCoordinator。
读者必须用read_committed才能屏蔽中止事务;LSO还会阻塞最早未决事务之后的记录。事务不是物理回滚磁盘文件,abort的记录仍可能存在于日志中而被过滤。长事务扩大可见延迟、打开状态和恢复成本,不能把一个小时的输入都攒成一笔事务。来源:UnifiedLog lastStableOffset。
把数据库写入夹在Kafka事务里,随后Kafka abort,不会撤销数据库已commit的余额;反过来数据库回滚也不会自动abort已经提交的Kafka事务。Kafka EOS的可用答案必须明确输入Kafka、处理状态和输出Kafka的边界,并说明隔离级别、身份与重放。消费者输出到任意系统就声称全链路exactly once,是范围错误。
原码:结束事务先校验任期,再准备决定
固定 apache/kafka / 3.7.1,TransactionCoordinator.scala L518–L531。以下连续原码只统一公共缩进,没有改写分支。
txnMetadata.inLock {
if (txnMetadata.producerId != producerId)
Left(Errors.INVALID_PRODUCER_ID_MAPPING)
// Strict equality is enforced on the client side requests, as they shouldn't bump the producer epoch.
else if ((isFromClient && producerEpoch != txnMetadata.producerEpoch) || producerEpoch < txnMetadata.producerEpoch)
Left(Errors.PRODUCER_FENCED)
else if (txnMetadata.pendingTransitionInProgress && txnMetadata.pendingState.get != PrepareEpochFence)
Left(Errors.CONCURRENT_TRANSACTIONS)
else txnMetadata.state match {
case Ongoing =>
val nextState = if (txnMarkerResult == TransactionResult.COMMIT)
PrepareCommit
else
PrepareAbortproducerId 不匹配、客户端 epoch 不相等/过旧、已有状态转换进行中,分别进入不同错误。只有通过这些门槛才从 Ongoing 选择 PrepareCommit 或 PrepareAbort;这段只是准备状态,后续仍需持久化决定与分发 marker。旧 producer 不能在新任期接管后随意结束同一事务;但 producer fencing 也不阻止它直接调用另一个数据库接口。
| 故障点 | committed 读者/位点的边界 | 恢复注意 |
|---|---|---|
| 输出追加后尚未完成事务 | 不能当作已提交业务输出读取 | 同一事务身份查证/恢复,未决事务可能阻塞 LSO |
| PrepareCommit 已持久化,marker 尚未稳定 | 局部磁盘存在不证明所有分区已可见 | 协调器恢复决定并继续 marker 流程 |
| Kafka 事务已提交,应用响应丢失 | Kafka 输出及所带 offset 按事务恢复 | 查原业务结果,不在外部数据库再无条件写一次 |
| 数据库已提交但 Kafka abort | 数据库效果不会随 Kafka 撤回 | 回查 Outbox 的两个原子域 |
带输入的推演与隔离实验
下载完整模型后可在文件所在目录执行 python3 reliability_lab.py replication --json;仓库根命令为:
python3 labs/middleware-senior/reliability_lab.py replication --json输入固定三副本、min ISR=2、目标 requiredOffset=105、初始 HW=103。输出三个显式快照:
| 步骤 | LEO / ISR | HW | Produce 等待结果 |
|---|---|---|---|
| 追加后 follower 尚落后 | [105,103,103] / [0,1,2] | 103 | WAIT |
| 全部追到目标 | [105,105,105] / [0,1,2] | 105 | ACK |
| 模拟另一未完成等待者在 HW 已达标后看到 ISR 收缩 | [105,105,105] / [0] | 105 | ERROR_AFTER_APPEND |
第三步不撤销第二步已经返回的 ACK;它展示同一边界在另一个待结束请求中可能得到错误,而不是“先成功后追回确认”。HW 也不会因为 ISR 减少从105倒退。实际 Kafka 检查发生时机、动态 ISR 扩张、leader 变更与磁盘失败不由这些快照模拟。另输出 HW110/open104→LSO104、107不可见;事务稳定后 LSO110。本轮已运行命令及公开断言;它验证限定边界计算,不是三 broker 复制实验。
真实实验需3.7.1发行包、三个broker、独立数据目录。端口按本机调整:
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create \
--topic reliability-lab --partitions 1 --replication-factor 3 \
--config min.insync.replicas=2
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe \
--topic reliability-lab
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 \
--topic reliability-lab --producer-property acks=all \
--producer-property enable.idempotence=true输入order-001并记录确认;停一个follower,等ISR变化后输入order-002;再停另一个输入order-003。预期前两次可满足确认,最后一次因副本不足拒绝或等待至失败,具体取决于请求时序。恢复后describe,再读消费者和日志核对第三条是否存在。要验证追加后分支,应在请求复制等待期间制造ISR变化;只在ISR已不足时发送只能覆盖追加前分支。单broker成功不能作为复制故障恢复证据。
事务推演固定HW110、open104,计算LSO104;清空open后LSO110。真实事务实验须用Java事务producer begin/commit/abort,普通console producer不能证明事务隔离。每个结果记录输入配置、动作时间、broker状态、日志身份和观察者隔离级别;未运行的结果标为预期,不能补写“典型日志”冒充实测。
RabbitMQ 对照与架构变化条件
RabbitMQ以exchange/binding/routing key决定路由,publisher confirm确认发布路径,consumer ack确认消费交付处理,二者独立。mandatory return可发现无法路由;随后即使收到confirm也不表示目标业务队列有消息。业务提交后ack仍有崩溃重投窗口,prefetch约束未确认投递数。本课程对照3.13文档,不把classic、quorum、stream视为同一复制协议。来源:RabbitMQ确认语义。
Kafka保留日志适合多组重放和流处理,RabbitMQ队列路由适合工作分发,两者都不能替代业务不变量。吞吐从2万变20万、消息从1KB变100KB时,瓶颈可能变成字节带宽、复制磁盘和压缩CPU。粗算三副本要存三份日志,再加索引、保留、重建和协议开销;重建流量不能挤占全部在线带宽。跨机房RTT从2ms升到80ms后,应比较批处理、可靠性域分层和延迟目标,直接切acks1会改变承诺。架构师需给出RPO/RTO、允许不可写时间、保留窗口和恢复验证,不止给配置名。
故障时先问哪个确认缺失
高级回答要给出 producerId/epoch/sequence、topic-partition-offset、HW/LSO、事务身份和业务键各自用途;架构师继续说明恢复保留期、不可写预算与重放责任。若吞吐目标变成20万条/秒但平均载荷从1KiB涨到100KiB,即便条数不变,字节与复制成本也涨100倍;先量网络/磁盘/压缩,不以换 acks 来掩盖原有耐久目标。
闭卷验收
本页 9 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。