Kafka消费者Offset深度解析:从提交机制到seek实践
学Kafka学到消费者这一块我才真正意识到为什么网上那么多Kafka重启就翻车的案例——绝大多数最后都能归到offset这一件事上。今天这篇是Kafka系列的第三篇接着前面生产者代码往下走把消费者代码里的offset拆开揉碎了讲清楚。这篇内容不挑语言虽然我代码示例用的是Linux下的C/C和librdkafka但offset这套概念在Java、Go里完全一样理解之后换语言就是换个API的事。1. 先把offset的基本概念盘明白不是全局编号是分区内的门牌号1.1 offset到底是什么一句话版本offset是消息在分区内的顺序编号从0开始递增每一条消息在所属分区里有且只有一个唯一的offset它决定了这条消息在分区里的先后位置。我更喜欢用门牌号来理解它。一个Topic下面挂着好几个分区Partition每个分区就是一栋独立的楼楼里的每个房间都有一个门牌号。消息进分区就相当于住进了某个房间门牌号就是offset。你要找一个楼里的所有住户从头走到尾顺序就是offset从小到大的顺序。这里要特别强调一个新手容易踩的点offset不是Topic级别的全局编号。同样是offset100的消息在分区0和分区1里是完全不同的两条消息没有任何关联。你给消息做唯一标识的时候必须用Topic Partition Offset三个值合起来才算数少一个都不行。另外消息的offset一旦分配就不会再变。消息存多久offset就保持多久除非消息因为过期清理被删掉了那这条offset就相当于从这个分区里消失了。这也是后面讲offset越界时会用到的前提。1.2 position、committed offset和auto.offset.reset三兄弟别搞混真正开始写消费者代码后我发现offset这个词在不同的语境下指的不是同一个东西。在Kafka的官方文档和librdkafka的头文件里至少有三个相关概念position当前读取位置消费者进程内存里维护的一个状态表示下一条要读的消息是哪个offset。它跟着poll的进度一直往前走进程一退出就没了。committed offset已提交的offset消费者把某个分区的读取进度同步到了Kafka服务端存在一个叫__consumer_offsets的内部Topic里。这个值是持久化的消费者重启之后靠它恢复读取进度。auto.offset.reset重置策略当某个分区没有可用已提交offset或者已提交offset越界时用这个配置决定从哪开始读。可选值是earliest、latest、none默认是latest。这三者的关系可以用一个场景串起来你的消费者凌晨三点宕机了重启之后它先去__consumer_offsets里查自己这个消费组在某个分区上最后提交的offset是多少。如果查到了且这个值仍然在分区的有效范围内就从那个offset接着读跟没宕机一样。如果查不到比如第一次启动或者换了个全新的group.id那就轮到auto.offset.reset出场了earliest从头读、latest只读新消息、none直接报错。这里有一个特别容易产生误解的名词——auto.offset.reset里的reset。它不是随时重置消费进度的意思不是你想让它从头读它就能从头读的。只有当Kafka确认这个分区对你这个消费组没有有效提交记录时reset才会生效。如果你想手动把消费进度退回去重新消费靠的不是这个配置而是后面要讲的seek或者重新提交旧offset。还有一个很多人没注意到的细节已提交offset的含义是下一条待消费消息的offset。比如你已经消费完了offset99那条消息提交的不是99而是100。这跟普通数组遍历的当前读到了哪个下标的直觉是反的很多同学写手动提交代码的时候在这里栽过跟头——提交了当前消息的offset结果重启后那条消息又被读了一遍造成重复消费。2. 消费者代码里offset的三种来源配置决定起点自动/手动提交决定进度2.1 auto.offset.reset的生效时机比你想的更严格我在学习日记里专门整理过一个表格把reset的生效场景和结果对照起来先贴出来场景行为备注新消费组第一次消费某分区按reset策略执行earliest从头latest从最新消费组有提交记录且offset在有效范围内从提交的offset继续读reset不生效消费组有提交记录但offset小于分区最低有效offset按reset策略执行消息过期被清了消费组有提交记录但offset大于分区最高有效offset按reset策略执行手动提交过未来offset也会这样reset设为none且无有效提交记录抛异常消费报错适合做严格校验场景这张表我建议收藏一下尤其是第三行和第四行。也就是说就算你已经跑了好几个月只要__consumer_offsets里的提交记录因为某种原因失效了最常见的就是消息超过retention.ms被清理了提交的offset已经小于log start offsetreset策略依然会生效。这个静默生效很容易让人以为Kafka出bug了。2.2 enable.auto.commit方便但坑也不少Kafka消费者默认开了自动提交也就是enable.auto.committrue。librdkafka里对应的配置项名一样。它的工作方式是这样的消费者每poll一批消息内部就记录一下当前读到了哪个offset然后每隔auto.commit.interval.ms默认5000毫秒异步地把进度提交一次。为什么说它方便但坑不少因为它提交的是最近一次poll到的offset而不是业务处理完成后的offset。假如你的消费流程是poll拉到一批消息写入本地数据库更新缓存而第二步和第三步比较慢超过5秒钟还没做完这时候自动提交已经把offset往前推进了。万一第三步之后、处理完之前进程崩了重启之后Kafka告诉你你已经消费到offset200了但实际上offset201到205的消息根本没有处理——这批消息就静默丢了。所以在处理成功才能算消费成功的业务里千万别用自动提交。这不是说自动提交一无是处如果你只是做日志转发、写个统计脚本消息丢了也无所谓或者你的消费逻辑很简单不会超过提交间隔那自动提交确实省心。2.3 什么时候必须手动提交我给自己定了三条标准只要命中一条就走手动提交消费过程中有外部IO写库、调接口、写文件要求at-least-once至少一次语义宁可重复消费也不要丢消息消费速度不均匀处理时长可能波动很大手动提交之后commit本身也有同步和异步之分。librdkafka的rd_kafka_commit_message最后一个参数是同步标志0表示同步1表示异步。同步提交的好处是commit返回时你确定Kafka收到了坏处是阻塞当前线程吞吐量会下降异步提交不阻塞但你无法立即判断是否失败。我的习惯是单分区消费用同步提交保证稳妥多分区高吞吐场景用异步提交再加个回调检测失败情况。3. 用librdkafka实操offset订阅、拉取、手动提交全流程3.1 一个能跑的消费者骨架先看一段完整的初始化代码。我用的librdkafka版本是1.9.xAPI在不同版本间略有差异但主体逻辑稳定。#include librdkafka/rdkafka.h #include stdio.h #include signal.h #include inttypes.h #include string.h static volatile sig_atomic_t g_run 1; static void sig_handler(int sig) { g_run 0; } int main(void) { char errstr[512]; rd_kafka_conf_t *conf rd_kafka_conf_new(); /* 基础连接配置 */ rd_kafka_conf_set(conf, bootstrap.servers, 127.0.0.1:9092, errstr, sizeof(errstr)); rd_kafka_conf_set(conf, group.id, study-group-01, errstr, sizeof(errstr)); /* 没有有效提交记录时从头消费 */ rd_kafka_conf_set(conf, auto.offset.reset, earliest, errstr, sizeof(errstr)); /* 今天先关掉自动提交专注手动提交流程 */ rd_kafka_conf_set(conf, enable.auto.commit, false, errstr, sizeof(errstr)); rd_kafka_t *rk rd_kafka_new(RD_KAFKA_CONSUMER, conf, errstr, sizeof(errstr)); if (!rk) { fprintf(stderr, create consumer failed: %s\n, errstr); return 1; } /* 创建高级消费者实例 */ rd_kafka_consumer_t *rk_c rd_kafka_consumer_new(rk, study-group-01, NULL); /* 订阅Topic */ rd_kafka_topic_partition_list_t *topics rd_kafka_topic_partition_list_new(1); rd_kafka_topic_partition_list_add(topics, study-topic, RD_KAFKA_PARTITION_UA); rd_kafka_resp_err_t err rd_kafka_subscribe(rk_c, topics); if (err) { fprintf(stderr, subscribe failed: %s\n, rd_kafka_err2str(err)); return 1; } signal(SIGINT, sig_handler); while (g_run) { rd_kafka_message_t *rkm rd_kafka_consumer_poll(rk_c, 1000); if (!rkm) { continue; } if (rkm-err) { if (rkm-err ! RD_KAFKA_RESP_ERR__PARTITION_EOF) { fprintf(stderr, consumer error: %s\n, rd_kafka_message_errstr(rkm)); } } else { printf(partition%PRId32 offset%PRId64 msg%.*s\n, rkm-partition, rkm-offset, (int)rkm-len, (char *)rkm-payload); } rd_kafka_message_destroy(rkm); } rd_kafka_consumer_close(rk_c); rd_kafka_destroy(rk); return 0; }编译时记得链接-lrdkafka运行前确认broker地址和Topic名正确。这段代码最重要的是看rkm-offset这个字段——它是librdkafka在rd_kafka_message_t结构体里直接暴露的消息元数据你不用额外去查poll回来的每条消息都自带offset。这里再补一个新手常常忽略的细节rd_kafka_consumer_poll返回的rkm用完必须调用rd_kafka_message_destroy释放。消息的payload内存是随消息一起管理的不释放会在长时间运行的程序里造成内存持续上涨跟offset无关但会干扰你排查问题。3.2 手动提交commit message和commit list两种姿势把上面代码的消费分支改成手动提交核心逻辑是拿到消息、处理业务、然后提交。注意提交的时机一定是在业务处理完成之后不能一拿到消息就提交。/* 拿到消息省略poll代码 */ rd_kafka_message_t *rkm rd_kafka_consumer_poll(rk_c, 1000); if (!rkm || rkm-err) { /* 错误消息不需要提交 */ if (rkm) rd_kafka_message_destroy(rkm); continue; } /* 1. 处理业务这里是模拟实际可能是写库、调接口等 */ process_message(rkm-payload, rkm-len); /* 2. 处理成功之后同步提交这条消息对应的offset */ rd_kafka_resp_err_t commit_err rd_kafka_commit_message(rk_c, rkm, 0 /* 同步 */); if (commit_err) { fprintf(stderr, commit message failed: %s\n, rd_kafka_err2str(commit_err)); /* 这里可以决定是重试还是记录待补偿 */ } rd_kafka_message_destroy(rkm);这里要重点解释rd_kafka_commit_message的offset语义。这个API是提交消息的offset但内部实现自动帮你做了加一操作即提交的是这条消息offset加一的结果。前面讲过已提交offset表示下一条要消费的消息从哪开始所以提交当前消息NKafka记录的是N1。我当初第一次看这个API的文档一度以为它提交的是N后来调试发现重复消费才弄明白写代码时千万别自己再做一次加一那就变成N2提交了会直接跳消息。如果你的业务是批量处理的一次poll拉了100条全部处理成功后可以用提交列表的方式一次提交完减少commit的次数/* 自己构造一个offset列表提交本批次最后一条消息的offset 1 */ rd_kafka_topic_partition_list_t *offsets rd_kafka_topic_partition_list_new(1); rd_kafka_topic_partition_t *tpar rd_kafka_topic_partition_new(study-topic, rkm-partition); tpar-offset last_offset_of_batch 1; rd_kafka_topic_partition_list_add(offsets, tpar); rd_kafka_resp_err_t batch_commit_err rd_kafka_commit(rk_c, offsets, 0); if (batch_commit_err) { fprintf(stderr, batch commit failed: %s\n, rd_kafka_err2str(batch_commit_err)); } rd_kafka_topic_partition_list_destroy(offsets);这种批量提交方式有个前提你确定这一批消息里的每一条都处理成功了。如果最后一条成功但中间某条失败了这一批都得重来靠下游做幂等去重兜底。批量构建rd_kafka_topic_partition_list_t的时候要小心一个分区只能有一个条目同一个分区追加多个条目会覆盖还是报错取决于版本我习惯先查一遍再构造。3.3 一个容易忽略的中间态offsets_store只存不提交librdkafka里还有一个容易被忽略的API叫rd_kafka_offsets_store。它的作用是把你处理过的offset存到消费者本地内存里但不提交到broker。这个API配合enable.auto.committrue使用时自动提交的就不再是最近poll到的offset而是你通过offsets_store显式确认过的offset。这套组合拳能解决一个经典问题既想要自动提交的省心又想保证处理成功才算数。你关闭enable.auto.commit后的自动提交能力改为处理成功后手动调rd_kafka_offsets_store然后在合适的时机触发提交。不过说实话这套机制首次接触时理解成本不低我建议先把纯手动提交跑熟再来看offsets_store否则容易把自动提交和手动提交的逻辑混在一起排错时非常痛苦。4. seek和查询offset的API消费进度由你掌控4.1 seek把消费指针拨回去手动提交解决的是进度往前进的问题那如果我想让消费进度倒退重新消费一批数据呢这时候用rd_kafka_seek。/* 把分区0的消费位置重置到offset500 */ rd_kafka_topic_partition_t *tpar rd_kafka_topic_partition_new(study-topic, 0); tpar-offset 500; rd_kafka_resp_err_t seek_err rd_kafka_seek(rk_c, tpar, 0); if (seek_err) { fprintf(stderr, seek failed: %s\n, rd_kafka_err2str(seek_err)); } rd_kafka_topic_partition_destroy(tpar);seek的offset语义和提交是一样的把位置拨到500下一条读的就是offset500的消息。如果你希望读完500以后不要了就拨到501。seek有几个限制条件我踩过之后印象很深只能seek当前已经分配给这个消费者的分区。消费者没订阅这个Topic或者分区还在重平衡rebalance过程中调用会返回RD_KAFKA_RESP_ERR__STATE错误。seek不会自动更新committed offset。它只改内存里的读取位置如果你seek完又什么都不提交就退出下次重启还是会从老的提交位置读你的seek等于白做。想要重放后持久生效得配合手动提交。订阅模式下对正在poll的消费实例做seek会有竞态问题。librdkafka的poll线程和你的主线程是并发的seek和poll同时进行时可能出现seek完了但poll还返回了旧位置的消息。稳妥做法是暂停pause这个分区seek再恢复resume。最后一个限制很多文章没写但实际项目里重放数据时特别容易触发。我现在处理重放需求的顺序是先调rd_kafka_pause_partitions把分区暂停再seek最后rd_kafka_resume_partitions恢复。这样能确保seek之后poll到的消息一定是从新位置开始的。4.2 query_watermark_offsets看分区的水位线除了seek日常运维还有一个高频需求看这个分区现在有多少消息了消费到哪了积压了多少。int64_t low 0; int64_t high 0; rd_kafka_resp_err_t err rd_kafka_query_watermark_offsets( rk, study-topic, 0, low, high, 5000); if (!err) { printf(partition 0: log_start_offset%PRId64 log_end_offset%PRId64\n, low, high); }这里的low就是log start offset也就是分区里现存最早的消息offsethigh是log end offset是下一条要写入的消息的offset不是最后一条消息的offset。所以分区的消息总量不是high - low 1而是high - low这个细节也容易算错。配合消费者的当前position你就能算出消费积压laglag high - consumer_position。consumer_position可以通过rd_kafka_position拿到当前各分区的position。这个组合是监控消费进度是否跟得上的基础工具我自己的脚本里就是定时拉这两个值超过阈值就告警。4.3 offsets_for_times按时间戳找offset还有一类场景我只想重新消费从昨天下午3点开始的数据但不知道那时的offset是多少。手动去翻日志找offset不现实Kafka提供了一个按时间戳查offset的APIrd_kafka_offsets_for_times。/* 目标时间戳单位毫秒 */ int64_t target_timestamp 1700000000000LL; rd_kafka_topic_partition_list_t *parts rd_kafka_topic_partition_list_new(1); rd_kafka_topic_partition_t *tpar rd_kafka_topic_partition_new(study-topic, 0); tpar-offset target_timestamp; /* 传入时是时间戳 */ rd_kafka_topic_partition_list_add(parts, tpar); rd_kafka_resp_err_t err rd_kafka_offsets_for_times(rk, parts, 5000); if (!err) { /* 返回后tpar-offset就是查到的消息offset */ printf(partition %d first offset timestamp: %PRId64\n, tpar-partition, tpar-offset); } rd_kafka_topic_partition_list_destroy(parts);这个API的语义是对每个分区返回时间戳大于等于你给定时间戳的第一条消息的offset。原理是Kafka存储消息时在索引里记录了按消息时间戳分组的位置信息broker收到查询请求后直接在索引里二分查找。用这个API有几个前提要知道broker版本不能太老时间戳索引是在0.10.1版本引入的老版本broker不支持。消息写入时如果没有指定时间戳broker会用当前时间填充所以时间索引基本都有值。如果查询的时间戳早于分区里最早消息的时间戳返回的offset就是log start offset如果晚于最后一条消息的时间戳返回的offset可能是-1表示这个分区里没有符合条件的消息代码里一定要对这个-1做判断不能直接拿去seek。5. 实际项目里踩过的offset相关的坑每一个都值得你记住5.1 改了group.id以为会从指定位置读结果从头开始这是我第一次做Kafka消费重放时的真实教训。当时想把线上数据重新读一遍想着改个group.id、把auto.offset.reset设成earliest然后重启消费者。逻辑上觉得新group没有提交记录earliest就是从最早读确实数据是从头读了。但问题在于我用这个新group消费完之后没有清理提交记录也没删除group。第二次再用同一个group名跑它以为自己有提交记录了直接从上次的位置继续读结果我期待的是从头读实际却从中间开始。从那以后我记住一条规则group.id和消费语义强绑定。如果你只是想临时改一下消费逻辑跑一遍全量数据跑完记得手动把group的offset清掉或者用别的工具删除group否则这个group残留的提交记录会一直影响之后的运行。5.2 手动提交放在处理前一崩就丢数据有段时间我维护的一个消费者程序偶尔会丢消息查了很久最后定位到提交代码的位置不对。当时的代码大概是这样的rd_kafka_message_t *rkm rd_kafka_consumer_poll(rk_c, 1000); rd_kafka_commit_message(rk_c, rkm, 0); /* 先提交 */ process_message(rkm-payload, rkm-len); /* 后处理 */提交放在处理前等于告诉Kafka这条消息我已经搞定了但实际业务还没执行。一旦处理过程中进程崩溃或者机器宕机消息就永久丢失。这个坑的根本原因是先提交后处理违背了at-least-once语义的基本要求——只有处理成功才能提交。这个顺序问题比什么API用错都隐蔽因为它大多数时候都能正常工作只在崩溃那一刻暴露问题。5.3 分区重平衡丢消息的表象本质还是offset没提交另外一次排障经历是消费者程序稳定运行但每隔一段时间就有几条消息重复消费把日志打开看重复的消息都出现在同一段时间内而且位置正好跨分区。最后确认是Consumer Rebalance引起的——消费者组成员变化比如某个实例挂了或扩容了时分区会在成员之间重新分配如果你的进度没有及时提交接管分区的那个消费者会从旧提交位置重新拉取于是出现看起来卡顿一下然后一批消息重新处理的现象。这种重复无法完全避免分布式系统里at-least-once语义就是允许重复的治理手段是让下游消费逻辑幂等或者用事务性消息方案。我的经验是先接受重复再想办法去重而不是跟分区重平衡机制较劲。5.4 时间戳查offset时多分区粒度不一致用offsets_for_times做按时间重放时还有一个容易忽视的细节它是按分区分别查询的。假设你的Topic有4个分区消息生产时间有先有后同一个时间戳在不同分区的对应offset可能差别很大。你把4个分区都查一遍拿到的4个offset分别seek回去消费出来的数据并不严格等于某个时间点之后的消息全集而是每个分区各自在该时间点之后的消息。简单说Kafka的消息没有全局时间序只有分区内的时间序。如果你的业务要求严格按时间顺序重放得先想清楚是单分区消费还是接受分区间的轻微乱序不能想当然地以为4个分区的查询结果拼起来就是一个整齐的时间轴。6. 一点自己的学习经验把offset这套概念和API捋顺之后回头看Kafka消费者代码其实核心就是两件事拉消息的正确姿势和进度提交的正确时机。前者靠poll后者靠你对offset语义的理解。我建议新手在本地起一个单节点Kafka创建一个小Topic手写一个消费者故意制造几种异常场景——不提交重启、提交位置往前调、seek到越界offset亲眼看看消息是怎么重复、怎么丢失、怎么报错的。这些现象看一遍比看十篇文档都管用。还有一个小技巧分享给做C/C的同学调试offset相关的逻辑时可以在环境变量里设置RDKAFKA_DEBUGcommit,consumer,cgrp再启动程序librdkafka会把消费组状态、提交请求、每次提交的offset都打到标准错误输出里。当年定位手动提交加一那个问题就是靠这个调试输出一锤定音的比自己写日志省事太多。