Python后端中间件专题29Kafka 与 RabbitMQ 不该互相替换——审计流的分区和 Offset招聘面试常问“你们既然用了 RabbitMQ为什么还要 Kafka”如果回答“Kafka 更快、更高级”面试官很容易追问通知任务失败该怎么重试审计员要从昨天某个时刻重读所有工单变更又怎么办这两种需求不是吞吐数字的比较而是消费语义不同。TicketFlow 的主线仍用 RabbitMQ/Celery 处理通知、索引和 SLA 任务可选 Kafka profile 只保留审计事件流。默认不启用 Kafka 时工单提交与通知也必须正常。我们沿着同一event_id观察“任务队列中的完成”和“审计流中的消费位置”再讨论为什么 offset 永远不能代替业务 receipt。上一课练习答案答案 EX-28-01 [code]下面的trace_carrier_probe.py使用真正的 OpenTelemetry span 与内存 exporter三段在同一进程运行仅模拟跨边界传递 carrier。它不是 Jaeger 或跨容器实测。fromopentelemetry.sdk.trace.export.in_memory_span_exporterimportInMemorySpanExporterfromticketflow.observability.tracingimportcreate_tracing exporterInMemorySpanExporter()tracingcreate_tracing(endpointhttp://collector:4318/v1/traces,exporterexporter,)try:withtracing.span(api):carriertracing.carrier()withtracing.span(relay,carriercarrier):passwithtracing.span(worker,carriercarrier):passfinally:tracing.close()spans{span.name:spanforspaninexporter.get_finished_spans()}assertset(spans){api,relay,worker}trace_ids{span.context.trace_idforspaninspans.values()}api_span_idspans[api].context.span_idassertlen(trace_ids)1assertspans[relay].parent.span_idapi_span_idassertspans[worker].parent.span_idapi_span_idprint(api - relay, worker: same trace and API parent)运行命令python trace_carrier_probe.py预期输出api - relay, worker: same trace and API parent两子 span 是 API span 的兄弟不是api→relay→worker的链式父子。TicketFlow 的事件在 API 提交 Outbox 时持久化 carrierrelay 和 Worker 从同一消息上下文恢复这保留业务操作的因果关联却不会凭空证明进程间网络交付。答案 EX-28-02 [prose]traceparent让日志/跨度能够追问“这条审计事件由哪次请求引起”采样丢了 trace 也不应影响业务。event_id配合数据库唯一 receipt 才负责“同一事件的持久化效果只记一份”。partition_keytenant_id:ticket_id让同一工单的事件进入同一 Kafka 分区offset 表示这个消费组在某个分区的下一读取位置。三种标识各司其职trace 可采样/过期offset 可回退/重放都不是 exactly-once 副作用保证。Kafka 停机不应阻断主线提交。TicketFlow 的 Kafka publisher 是独立可选进程从已提交 Outbox 读取事件并有独立的 publish receipt主 API/relay/Worker 不等待可选 profile。停机期间审计积压恢复后补发、消费仍以event_id唯一约束收敛重复。两种队列两个问题RabbitMQ/Celery 的通知任务主要问“谁来做这件工作、失败后何时再试、毒消息怎么隔离”。消费者 ACK 后普通队列里的该交付就完成需要长期审计不该指望这条任务消息永远留着。Kafka 的审计 topic 主要问“按 key 保留多少历史、哪个消费组读到了哪个 offset、要不要重放”。它不是工单数据库审计记录不可替代最新 title/status 的事实源retention 和分区数决定了能重读多远。Kafka 同一 partition 有序不代表跨 partition 全局有序。用tenant_id:ticket_id作为 key能让同一工单的事件落在同一分区而不是让整个租户挤在一个热点分区。按真实代码走一遍审计写入kafka_publisher.py从 Outbox 中挑出尚无KafkaPublishReceiptModel的事件把event_id、租户、工单、动作、时间和 payload 编成 JSONproducer.send(..., keypartition_key(...)).get(timeout...)等待 broker 确认才在数据库记 publish receipt。若 broker 已收但确认丢失它可能重发同一 event这和 14 的 Outbox 不确定窗口相同不能宣传 producer exactly-once。consumerkafka_runner.py设置enable_auto_commitFalse、稳定 group ID、auto_offset_resetearliest。它把消息解成AuditEvent调用AuditConsumer.consume()SQL audit_events(event_id unique) 持久化 → commit(topic, partition, offset 1)即使同一事件在 offset 41、42 又出现第二次apply_once返回 false仍可以提交第二个位置因为效果早已存在。相反数据库写失败时绝不能推进 offset下一次才能再试。把核心规则跑成最小脚本保存为audit_offset_probe.pyfromticketflow.messaging.kafka_auditimport(AuditConsumer,AuditEvent,InMemoryAuditStore,partition_key,)eventAuditEvent(event_idevt-29,tenant_idtenant-a,ticket_idticket-7,actionticket.updated,occurred_at2026-09-22T10:00:0000:00,payload{version:3},)storeInMemoryAuditStore()commits[]consumerAuditConsumer(store,commitlambdatopic,part,offset:commits.append((part,offset)))assertpartition_key(event)btenant-a:ticket-7firstconsumer.consume(topicticketflow.audit,partition2,offset41,eventevent)duplicateconsumer.consume(topicticketflow.audit,partition2,offset42,eventevent)assertfirst.appliedandnotduplicate.appliedassertlen(store.rows)1assertcommits[(2,42),(2,43)]print(one audit row; committed next offsets 42,43)运行命令python audit_offset_probe.py预期输出one audit row; committed next offsets 42,43这个内存 store 验证控制流真实持久性由SqlAlchemyAuditStore、迁移唯一键和远端 Kafka profile 另行验收。本课检查点与可选服务边界正式 selectorpython tools/verify_executable_revisions.py--lesson 29--python python命令在 clean Python 3.11 环境安装本课 revision并运行 baseline 与 09–29 的全部历史合同验证 offset 在处理返回后推进、重复事件不再添加一行、生产确认先于 receipt。它不能证明 broker partition 分配、consumer group 重平衡或真实 Kafka offset 重放。这套服务是明确隔离的可选边界compose.yml把 Kafka 相关服务放进profiles: [kafka]默认启动不会创建它们Dockerfile.kafka用.[kafka]安装专用客户端依赖核心Dockerfile不带 Kafka。kafka-init创建三分区 audit topickafka-publisher和kafka-audit都等它完成并各自只依赖 Kafka、PostgreSQL 与 migrateAPI、relay、worker 的默认路径不依赖 Kafka。换句话说审计流坏了是可观察的积压不该把工单主线一起拖住。正式真实 selector 只在已获授权的 Ubuntu 远端、Kafka profile 已按远端流程启动且TICKETFLOW_REMOTE_TESTS1时运行python scripts/run_remote_selector.py--checkpoint 29--selector tests/integration/test_kafka_remote.py::test_real_kafka_ack_partition_key_and_duplicate_consumer_converge它观察 producer ACK 之后才有 SQL publish receipt、tenant_id:ticket_id分区 key、重复 event 最终只保留一行并且 live consumer offset 推进。这里没有在 Windows 本地启动 Docker 的命令上述远端 selector 未运行就是PENDING不能写成 PASS。故障定位审计行没出现按 Outbox→Kafka publish receipt→topic/partition→group lag→SQL unique receipt 查同一工单事件乱序核对 key 是否相同、生产端是否在单分区按版本送出offset 前进但 SQL 无行优先检查是否开启了 auto commit 或在存储之前提前提交。本课练习练习 EX-29-01 [code]写audit_fail_probe.py一个 fake store 的apply_once()抛OSError(database unavailable)传给AuditConsumer断言异常传出且 commit 列表为空打印failure leaves offset uncommitted。下一篇给完整答案。练习 EX-29-02 [prose]同一工单的 v2/v3 事件为什么要用相同 partition key如果两个不同工单在不同分区能否承诺它们之间的全局先后审计库看到重复 event_id 时为什么可以提交后一个 offset下一篇有意重放不是误打误撞重复第 30 课会让审计消费者在数据库故障前后重试同一 offset再讨论 group ID、earliest、人工重放边界和“投递至少一次”为什么仍能得到一份持久效果。本课完整核心模块messaging/kafka_audit.py以下lesson-29模块只覆盖审计事件、分区键、消费控制流与内存实验存储第 30 课再加入 SQL 持久化。Kafka 进程入口和 Compose profile 在相邻项目文件不隐藏在这个模块里。from__future__importannotationsfromdataclassesimportdataclassfromtypingimportCallable,Mapping,Protocoldataclass(frozenTrue,slotsTrue)classAuditEvent:event_id:strtenant_id:strticket_id:straction:stroccurred_at:strpayload:Mapping[str,object]defpartition_key(event:AuditEvent)-bytes:Preserve per-ticket ordering without routing a whole tenant to one partition.returnf{event.tenant_id}:{event.ticket_id}.encode(utf-8)classAuditStore(Protocol):defapply_once(self,event:AuditEvent)-bool:...dataclass(frozenTrue,slotsTrue)classConsumeResult:applied:boolnext_offset:intclassAuditConsumer:Commit Kafka position only after the idempotent storage boundary returns.def__init__(self,store:AuditStore,*,commit:Callable[[str,int,int],object],)-None:self._storestore self._commitcommitdefconsume(self,*,topic:str,partition:int,offset:int,event:AuditEvent,)-ConsumeResult:appliedself._store.apply_once(event)next_offsetoffset1self._commit(topic,partition,next_offset)returnConsumeResult(appliedapplied,next_offsetnext_offset)classInMemoryAuditStore:Deterministic lab store; production uses the same unique event-id contract.def__init__(self)-None:self._seen:set[str]set()self.rows:list[AuditEvent][]defapply_once(self,event:AuditEvent)-bool:ifevent.event_idinself._seen:returnFalseself._seen.add(event.event_id)self.rows.append(event)returnTrue