做文档处理系统的同学,大概率都经历过这样的崩溃瞬间:平时接口跑得好好的,一到月底日终任务或者业务方批量补录数据,几万份 PDF、Word、扫描件像潮水一样涌进来,同步调用直接被打趴,超时、重试、连接池耗尽,一层一层往上压,最后把整条业务链路拖到不可用。我第一次碰到这个场景是在一个文档归档项目里,当时还因为"为什么不直接同步调用"跟团队争论过,后来被现实教育得很彻底。
重构之后,我把链路改成了 Kafka 消费者 + 文档处理管道:上游只负责把文档任务投递到消息队列,消费端按自身吞吐能力处理下载、解析、OCR、落库。这套 Kafka 消费者与文档处理系统上线后稳定运行了很久,中间踩了不少坑,也沉淀了一套完整的调参和排障经验。
这篇文章就把这套系统的技术文档整理出来,重点讲几个方面:为什么用 Kafka 而不是普通任务表、Topic 和消息体怎么设计、消费端链路每一步在干什么、参数和容量怎么估算、上线后遇到过的典型故障以及排查思路。如果你正准备搭一套异步文档处理管道,或者正在维护一个已经跑起来的 Kafka 消费链路,这篇应该能帮你少走几步弯路。
1. 为什么我坚持把文档处理改成 Kafka 异步链路
1.1 同步方案在高峰期是怎么崩掉的
最早接入的时候,业务方只有每天几百份的量,我图省事,直接给上游开了一个同步 HTTP 接口:文档传过来,服务端解析完,把结构化字段返回回去。一套流程走下来很顺,接口也就百来行代码。问题出现在量变大之后。
文档处理跟普通查询接口不一样,它的单次耗时非常不稳定。一个 3~5MB 的扫描件,下载要花几百毫秒,OCR 识别可能要 1 到 3 秒,再加上字段提取、入库,单份文档整体耗时常年在 2 秒以上。日终批量任务一启动,上游按几千份每分钟的速率调接口,服务端线程很快就全部被占住。结果就是客户端超时、自动重试,重试请求又打到已经不堪重负的服务上,连接池耗尽,数据库连接也不够用,最后整个业务集群一起报警。
那会儿最简单的直觉反应是"加机器、调线程池"。但仔细观察会发现,真正的痛点不是机器不够,而是峰值流量没办法被削掉。上游的批量任务往往集中在某个时间段,你按峰值去扩容,大部分时间资源又是闲置的。而且文档处理天然适合异步——调用方并不需要立刻拿到解析结果,只要任务被可靠地收下来,迟早能被处理完。既然这样,中间加一层队列就是更合理的思路。
1.2 Kafka 在这里解决的三个核心问题
用 Kafka 之后,整个系统的行为模式发生了本质变化,我总结下来就是三件事。
第一,削峰填谷。上游继续以高峰速率投递消息,但消费端不用跟着这个速率走。Kafka 在 Broker 端把消息存下来,消费者按自己的处理能力一点点消费。高峰期再猛,消息也只是在队列里短暂积压,系统不会再被打崩。
第二,链路解耦。生产者不需要知道消费者怎么解析、怎么落库、用了什么算法。消费端内部想做结构调整,比如新增一种文档类型、更换 OCR 引擎,只需要在自己这一侧改,上游完全不用动。这种边界清晰的感觉,在多人协作的项目里价值很大。
第三,消息可重放。Kafka 的消息默认会保留一段时间(比如 7 天),消费位置由消费者自己管理。这意味着两种情况特别受益:一是消费逻辑刚上线有 bug,修完之后可以重置位移重新消费;二是业务规则改了,旧文档需要按新逻辑重新走一遍,直接换一个新的消费组从头消费就行。这在同步架构里几乎做不到。
1.3 这套方案并不是万能的
我也得说句公道话:Kafka 不是文档处理系统的银弹。如果对端要求秒级返回解析结果,前端传完文件就得马上拿到字段,那异步链路就不合适,应该在同步服务里想办法优化单次解析性能,而不是套队列。
如果业务量很小,一天只有几十份文档,同步调用反而简单直接。引入 Kafka 意味着要多维护一套集群、多学一堆概念、多写消费和监控代码,这种成本对小体量系统来说是实打实的负担。还有一个容易被忽略的点:消息队列会掩盖掉一些下游问题。消费端如果一直处理失败,消息就会积压,积压久了业务方看到的结果是"文件一直没解析出来",排查链路比同步调用更长。所以选型之前,先把同步还是异步这个判断题做对,后面才谈得上架构设计。
2. Topic 与消息体设计:后面所有坑的起点
2.1 主链路、重试、死信:三条 Topic 的划分方案
Topic 设计属于那种"改起来很痛、所以一开始就要想清楚"的事情。我最终用的是三条 Topic:主任务 Topic(例如doc-task)、重试 Topic(doc-retry)、死信 Topic(doc-dlq)。
为什么不按文档类型拆成多个 Topic?比如 PDF 一个 Topic、Word 一个 Topic、图片一个 Topic。当时评估过,按类型拆确实可以让消费端针对性地配解析器,比如 OCR 业务单独跑一组消费者。但代价也很明显:生产者侧要写路由逻辑,消费端要维护多个消费组,Topic 越多,监控和运维成本越高。在文档种类还不算多的阶段,我选择在一个主 Topic 里用docType字段做标记,消费端拿到消息后按类型路由到不同的解析器。等某类文档的量级和优先级真的拉开差距,再拆 Topic 也不迟,这个演进路径比较平滑。
重试和死信 Topic 是从第一天就要留出来的。文档处理出错率不低,坏文件、格式异常、下游临时故障都是常态。如果所有失败消息都留在主 Topic 里反复消费,很快就会出现一条毒消息卡住整个分区的情况。后面第 5 节会详细讲这个。这里先记住结论:主链路、重试、死信三条道分开走,处理逻辑会清爽很多。
2.2 消息体只放元数据,文件本体交给对象存储
这是整个设计里我最想强调的一点。早期原型阶段,我图省事,直接把文件转成 base64 塞进消息体。结果第一个超过 1MB 的文件就把生产端打爆了——Kafka 默认的单条消息最大 1MB,普通文档随便一张扫描件就超了。后来虽然可以通过改 Broker 配置把上限拉高,但那意味着所有 Broker 都要调整,消息在网络里传输和落盘的开销也会变大,日志分区里存着一堆几 MB 的大对象,排查问题的时候连消息内容都懒得看。
正确的做法是文件本体放对象存储,Kafka 消息里只放元数据。我现在的消息体长这样:
{ "taskId": "task_202405170001", "bizType": "contract", "docType": "pdf", "storageKey": "2024/05/17/xxx.pdf", "fileName": "采购合同-编号B1024.pdf", "fileSize": 5242880, "sha256": "a94a8fe5...", "produceTime": 1715900000000 }消费者的流程变成:读消息 → 根据storageKey从对象存储下载文件 → 校验sha256一致性 → 解析 → 落库。文件本体和任务描述彻底分离,Kafka 消息永远只有几百字节,Producer 再也不会有消息过大的问题,Broker 的压力也小得多。
这里还有一个额外的好处:按sha256做文件去重非常方便。同一个文件被多个业务方重复提交的场景很常见,消费端如果发现sha256已经处理过,可以直接跳过解析,节省大量 OCR 资源。如果文件内容直接塞在消息里,这个去重就没法做,因为解析前还得先比对内容。
2.3 分区键决定并发上限与处理顺序
分区键的选择直接决定了三个东西:消息分布是否均匀、消费并发上限是多少、同一种业务的数据能不能保证顺序处理。
最简单的做法是用taskId做分区键。taskId基本是随机的,消息会均匀散到各分区,吞吐量最均衡。如果业务上要求"同一个客户的多份文档必须按顺序处理",那就要用customerId做分区键,因为 Kafka 只能保证同一个分区内消息有序。同一个customerId的消息会永远进同一个分区,天然保证顺序。代价也很直接:某些大客户文档量特别大的话,对应分区就成了热点,其他分区空闲着,整体吞吐被拉低。
这里有个常被忽视的结论:消费组内的有效并发上限等于分区数。分区有 6 个,消费者实例无论开多少个,同一时刻最多也只有 6 个在真正消费,多余的消费者只能空转等待 rebalance。所以扩容之前,先想清楚自己瓶颈在分区还是消费者。另外,给已有 Topic 增加分区要谨慎,如果业务依赖分区键来保证顺序,增加分区后,同一个 key 的消息可能被分到不同的新分区,顺序保障就被破坏了。
3. 消费端完整链路:从拉取消息到文档落库
3.1 消费主循环与位移提交时机的取舍
消费端最基础的骨架是 poll 循环。我用的 Java 客户端大致长这样:
Properties config = new Properties(); config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "10.0.3.11:9092,10.0.3.12:9092"); config.put(ConsumerConfig.GROUP_ID_CONFIG, "doc-processor"); config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); config.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "50"); config.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, "600000"); try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(config)) { consumer.subscribe(Collections.singletonList("doc-task")); while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500)); Map<TopicPartition, OffsetAndMetadata> toCommit = new HashMap<>(); for (ConsumerRecord<String, String> record : records) { processSingle(record); toCommit.put( new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset() + 1)); } if (!toCommit.isEmpty()) { consumer.commitSync(toCommit); } } }最关键的一行是enable.auto.commit=false。默认的自动提交是按固定间隔提交当前位移,它根本不关心消息有没有处理成功。如果消费端在处理一批消息的过程中崩溃,重启后位移已经提交到前面去了,那批没处理完的消息就永远断了。所以文档处理这种不能丢任务的场景,必须关掉自动提交,改成处理完一批再手动提交。
手动提交也有讲究。commitSync会阻塞到提交成功,适合每批处理完后的收尾动作,虽然慢一点,但语义清晰。commitAsync不阻塞,吞吐更好,但异步回调失败时有个隐患:如果提交 offset+1 失败,offset+2 反而成功了,下次重启会从 offset+1 重新消费,造成重复。我的选择是批处理收尾用commitSync,出错时可以根据异常决定重试还是人工介入。因为文档处理本身就不追求极致的吞吐,稳定比性能重要。
3.2 文档类型识别与解析器注册表
消费端拿到消息后,最忌讳的就是写一大坨if (docType.equals("pdf")) ... else if ...。文档类型只会越来越多,每加一种类型就动一遍主链路代码,迟早出错。我用的方案是解析器注册表:每种文档类型对应一个独立解析器,实现同一个接口。
public interface DocumentParser { boolean supports(String docType); ParseResult parse(DownloadedFile file) throws ParseException; }调用侧维护一个解析器列表,按supports(docType)找到合适的实现:
List<DocumentParser> parsers = List.of( new PdfTextParser(), new WordParser(), new OcrImageParser() ); DocumentParser pick(String docType) { return parsers.stream() .filter(p -> p.supports(docType)) .findFirst() .orElseThrow(() -> new UnsupportedDocumentTypeException(docType)); }新增一种文档类型时,只需要新写一个XxxParser加进列表,完全不动消费主流程。这是我在这个项目里做过的最值钱的抽象之一。
还有一个细节容易翻车:不要只相信文件后缀名。很多系统生成的 PDF 文件后缀可能是乱的,甚至有些"PDF"其实是图片套了个壳。解析前最好用文件头部的魔数做二次识别,再根据识别结果决定走文本解析还是 OCR 路线。扫描件与文本型 PDF 的区分也很关键,我们的策略是抽取第一页文本,如果文本密度低于阈值就走 OCR。原因很直接:OCR 比文本提取慢一个数量级,把文本型 PDF 误判成扫描件,处理时间会爆表;把扫描件误判成文本型,解析出来的就是一堆空字段,下游数据质量直接崩。
3.3 重试分层与死信策略:把可恢复和不可恢复分开
文档处理链路里的失败,必须分成两类看待。一类是暂时性失败,比如对象存储临时抖动、数据库连接超时、下游搜索服务瞬时不可用,这类失败过几秒再试大概率能成功。另一类是永久性失败,比如文件校验和不匹配、文档加密打不开、文档类型不受支持,这种失败重试一万次结果都一样。
我实现的时候分了两层。消费进程内最多立刻重试 3 次,每次间隔递增(1 秒、5 秒、15 秒),3 次仍然失败,判断一下异常类型:永久性失败直接投递到doc-dlq;暂时性失败投递到doc-retry,由专门的定时消费任务在几分钟后重新拉起来跑。doc-retry里每条消息记录重试次数,超过最大上限(比如 8 次)就转移到doc-dlq,同时触发告警。
这里想强调一下,千万不要把失败消息无限重新入队。一条损坏的 PDF 每次解析都在同一个地方挂掉,如果你无条件把它重新塞回队列,它就会一直在分区头部卡着,后续正常文件全部排队等着,消费组的 Lag 越堆越高。这种"毒消息"必须用死信机制隔离出去,让它不阻塞正常业务,再靠人工或者告警机制去处理。
3.4 幂等处理:为什么要建一张任务状态表
Kafka 默认提供的是at-least-once语义:消息不丢,但可能重复。重复的来源很常见:消费端处理完一批消息、还没提交位移就崩溃了,重启后这批消息重新消费;重试框架把同一任务投递了两次;甚至 Producer 端重试也可能产生重复消息。所以别把重复当成异常情况,它在这个架构里是常态。
我处理重复的办法是建一张任务状态表,用task_id做唯一约束,消费端执行解析前先"占坑",处理成功后更新状态。这样无论消息重复多少次,同一份文档只会被真正解析一次。
CREATE TABLE doc_task ( task_id VARCHAR(64) PRIMARY KEY, storage_key VARCHAR(512) NOT NULL, file_sha256 CHAR(64), doc_type VARCHAR(32), status VARCHAR(16) NOT NULL DEFAULT 'NEW', retry_count INT NOT NULL DEFAULT 0, parse_result JSON, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL );状态机大概是NEW -> PROCESSING -> SUCCESS / FAILED。消息进来先尝试插入一条PROCESSING状态的数据,插入成功说明没人处理过,可以放心干活;插入失败说明task_id已经存在,去查一下状态,如果已经是SUCCESS,直接跳过;如果是PROCESSING,要小心可能有另一个线程正在处理,用updated_at判断是否超时,超时了再接管。这套逻辑不复杂,但能帮你扛住大部分重复消费场景。想清楚这个之后再去看各种"精确一次消费"的资料,会发现大多数情况下我们根本用不到那么高级的机制,一张唯一键表就够了。
4. 消费参数与容量估算:那些文档里不会写清楚的数字
4.1 一组可以直接抄的消费端参数配置
Kafka 消费端的默认参数很多是给通用场景设计的,文档处理这种"单条消息重、处理时间长"的场景,直接跑默认参数大概率出问题。我实际用的是下面这组:
| 参数 | 默认值 | 我调成 | 理由 |
|---|---|---|---|
| enable.auto.commit | true | false | 防止未处理就提交位移导致丢任务 |
| auto.offset.reset | latest | earliest | 新消费组从头消费,不放过积压任务 |
| max.poll.records | 500 | 50 | 控制单批处理时长和内存占用 |
| max.poll.interval.ms | 300000 | 600000 | 避免文档解析耗时超过后被踢出消费组 |
| session.timeout.ms | 45000 | 30000 | 配合心跳更快感知消费者失联 |
| heartbeat.interval.ms | 3000 | 3000 | 保持默认,心跳与主线程独立 |
| max.partition.fetch.bytes | 1048576 | 2097152 | 防止大元数据消息被截断 |
配置落到 YAML 里大概是这种感觉:
doc-processor: group: doc-processor bootstrap: 10.0.3.11:9092,10.0.3.12:9092,10.0.3.13:9092 topics: [doc-task] enable-auto-commit: false max-poll-records: 50 max-poll-interval-ms: 600000 session-timeout-ms: 30000 heartbeat-interval-ms: 3000 retry-topic: doc-retry dlq-topic: doc-dlq这里最核心的逻辑是max.poll.interval.ms和文档处理耗时的关系。消费者拉取一批消息后,如果处理完整批的时间超过了这个阈值,协调器就会认为消费者失联,把它踢出消费组,触发重平衡。文档处理单条消息经常要好几秒,max.poll.records又默认 500,一批跑下来极容易超时。所以我把max.poll.records调小,同时把max.poll.interval.ms调大,双管齐下。但这只是治标,真正彻底的方案是把耗时操作挪出 poll 线程,下面讲。
4.2 消费模型:串行、线程池与暂停恢复
最朴素的消费方式就是在 poll 循环里同步处理每条消息,处理完再 poll 下一批。优点是实现简单、位移语义清晰,缺点也很明显:吞吐太低了。如果平均一份文档耗时 2 秒,一个消费者串行跑一小时只能处理 1800 份,还得时刻提心吊胆别超过max.poll.interval.ms。
提升吞吐的常规做法是 poll 线程只负责拉消息和提交位移,真正解析文档的逻辑丢给线程池。大概是这个思路:poll 拉一批消息,把每条任务提交给 worker 线程池,poll 线程继续拉下一批;每批任务的结果收集回来后,统一提交这批位移。要注意的是,必须跟踪每个分区正在处理的最大 offset,提交位移不能越过还没处理完的消息。
这个模型里有一个让链路稳定的关键动作:暂停分区(pause)。如果 worker 线程池的任务队列已经积压到一定水位,说明下游处理能力跟不上了,这时候 poll 线程要主动consumer.pause(partitions),暂停从这些分区拉消息,等队列水位下降再consumer.resume(partitions)。这样系统就形成了天然的背压机制,不会让队列无限堆积。我见过不少团队只开线程池不做 pause,结果消费速率没控制住,线程池任务队列越积越长,最后内存直接爆炸。
另外记住一条铁律:同一个消费组内的消费者实例数不能超过分区数。6 个分区配 10 个消费者,那 4 个消费者纯粹是摆设,还会反复触发 rebalance,白增加抖动。想要提升并行度,要么增加分区,要么像刚才那样在单个消费者撑倍数。
4.3 动手估算吞吐量的方法
很多人在设计阶段不估算容量,等系统上线被流量教做人。我分享一个简单的估算方法。假设业务高峰每小时 6000 份文档,平均单份文档从下载、解析到落库需要 2 秒。那高峰速率是 6000/3600 ≈ 1.7 份每秒,单条串行处理链路每 2 秒处理 1 份,就是 0.5 份每秒。要让消费跟得上,需要至少 1.7 / 0.5 ≈ 4 条并行链路,考虑到峰值冗余一倍,就是 8 条。如果每个消费者配 4 个 worker 线程,就需要 8 / 4 = 2 个消费者实例,分区数至少 4 个,保守一点建 6 个分区。
这只是解析侧的计算。别忘了还要反推网络和存储:高峰每秒 1.7 份,平均每份 3MB,对象存储下载流量就是 5MB/s 左右,加上 OCR 资源占用,如果扫描件占比高,单份耗时可能要再翻倍,估算时要把最坏情况乘上去。
还有一点很重要:用你自己的文档样本做基准测试,不要照抄别人的平均耗时。不同分辨率扫描件、不同大小的 PDF,OCR 耗时差异可能是 10 倍。我后来养成的习惯是每次换解析引擎之前,先拿历史真实文档跑一遍压测,记录耗时分布,而不是只看平均值。这个数字直接决定你要开多少个分区,值得花这个时间。
5. 上线后我遇到过的四个典型故障和排查过程
5.1 消费组频繁 rebalance:max.poll.interval.ms 被默认值坑了
上线初期最诡异的现象是消费组每隔几分钟就 rebalance 一次,日志里反复出现消费者被踢出的记录,同时产生了大量重复文档。一开始以为是网络问题,后来发现不是。
排查过程是这样的:先看消费者日志,发现它发出的心跳是正常的,但协调器仍然把它判定为超时。真正的原因藏在处理耗时上,某一份 30 页的扫描件 OCR 跑了 7 分多钟,而默认的max.poll.interval.ms只有 5 分钟。消费者拉了一批消息后,整个 poll 线程陷在处理里出不来,虽然心跳线程还在跳,但协调器等到max.poll.interval.ms超时后直接判定消费者失联,把它踢出了组。
修复分两步。第一步把max.poll.interval.ms调大到 10 分钟,先止住 rebalance。第二步才是治本:把文档解析从 poll 线程挪到独立 worker 线程池,poll 线程只负责拉消息和提交位移。这样即使单份文档耗时再长,也不会阻塞 poll 节奏。经验就是:遇到 rebalance 先别急着怀疑网络,先量一下自己的单批量处理时长。
5.2 大文件消息让分区无法前进:把文件塞进 Kafka 的代价
我前面讲的故事里,最初把文件转 base64 塞进消息体的版本,上线当天就翻车了。生产端投递一个 8MB 的 PDF 时直接抛RecordTooLargeException,因为 Broker 端默认message.max.bytes是 1MB。当时为了应急,我们提高了 Broker 的message.max.bytes和replica.fetch.max.bytes,消息是可以发过去了,但新问题接踵而来:大消息在整个集群里复制、落盘的开销都变大,消费端max.partition.fetch.bytes也得跟着调整,不然一条大消息永远拉不下来,消费进度卡死。
那次事故之后我下定决心整改,把文件全部挪到对象存储,Kafka 消息只传元数据。如果你也遇到大文件入 Kafka 的需求,我的建议很明确:不要硬调 Broker 上限,而是把文件体搬出去。消息体变小之后,Broker 配置文件不用动,消费端也不用动,整个链路的稳定性立刻上了一个台阶。
5.3 坏文件变成永动机:重试逻辑没区分失败类型
系统跑了一段时间,某天告警说重试 Topic 的消息数量暴涨。我去看的时候发现,一条损坏的 PDF 文件成了"永动机":解析每次都在同一个地方报错,重试逻辑却不分青红皂白,把异常当成暂时性失败重新投递,每 5 分钟重试一次,重试了一整天都没停。
问题出在重试条件上。我当时写的重试逻辑只判断"失败的都重试",没有把永久性失败识别出来。正确的做法是建立异常分类:文件校验失败、加密文件打不开、文档类型不支持这些属于永久性失败,一旦发生直接进doc-dlq;对象存储超时、数据库连接中断这些才是暂时性失败,值得重试。重试次数也要有上限,比如 8 次,超过上限不管什么类型都进死信,让告警出来接管,而不是让一条毒消息无限占据重试队列。这个分类逻辑花不了多少代码,但能避免非常多的生产事故。
5.4 消费没停,Lag 却一直涨:先看分区边界再看环节耗时
另一个典型故障是消费者看起来一切正常,CPU、内存都不高,但消费 Lag 只增不减。我排查的顺序是固定的。
第一步,先看消费组里消费者实例数和分区数的关系。如果 6 个分区只有 3 个消费者,那就意味着有 3 个消费者在同时处理 6 个分区的负载,每个消费者负责 2 个分区。这时候单纯加消费者实例是没有用的,因为它不能超过分区数,要么加大分区,要么给现有消费者加 worker 线程。第二步,量一下每个处理环节的耗时分布。我们用埋点记录了下载、解析、落库三个阶段各自的中位数和 P99,发现瓶颈在落库:当时每条处理结果都是逐条 INSERT,数据库写得太慢。改成批量 INSERT 之后,同样的消费者吞吐直接翻倍。
这个案例给我的启示是:Lag 只是一个结果指标,真正有用的中间指标是"每阶段的处理耗时"和"每分区的消费速率"。定位瓶颈的时候,按"分区数够不够 -> 哪个环节慢 -> 资源是不是到顶"这个顺序来,比拍脑袋加机器高效得多。
6. 监控、日志跟踪与设计复盘
6.1 Lag 监控的第一道防线
Kafka 消费端最需要盯的指标就是消费 Lag。我日常用的命令是官方的kafka-consumer-groups.sh:
kafka-consumer-groups.sh \ --bootstrap-server 10.0.3.11:9092 \ --describe \ --group doc-processor输出长这样:
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG doc-processor doc-task 0 12450 12450 0 doc-processor doc-task 1 9832 9834 2看 Lag 不能只盯某一个瞬间的值,更重要的是看趋势。Lag 短暂波动很正常,但如果某个分区 Lag 持续上涨,八成是那台消费者处理出问题了;如果所有分区 Lag 一起涨,那就是整体吞吐跟不上生产速率,需要回到第 4 节的方法去定位。另外,我给 Lag 设了分级的告警阈值,比如超过某个分区消费速率的 10 分钟处理量就告警,低于这个值只记录不打扰。把告警收敛到"真出事了才响",监控才留得住人看。
6.2 日志里的链路追踪标识
文档处理链路跨越了生产者、消费者、对象存储、数据库四个环节,没有关联标识的话,排查一条失败文档的完整路径会非常痛苦。我在生产者生成taskId后,把它写进消息体,同时塞到消息 header 里。消费者端在日志框架里设置 MDC,把taskId作为 traceId 贯穿整条链路。这样日志里任意一行的上下文都能看到当前处理的文档 ID,想查某个文档的完整过程,直接按taskIdgrep 就出来了。
实际排查效果很直观:业务方反馈"某份合同一直没解析出来",第一步就是拿taskId查消费日志,立刻能看到它停在哪一步、报了什么错。没有这个标识的时候,你得在各个系统日志里翻来翻去,几分钟能解决的事情可能要折腾半小时。
6.3 如果让我重新做一遍,我会早一点做的三件事
现在回头看这个项目,有一些决定如果能早点做,后面会省很多事。
第一,先把消息契约定下来并且带上版本号。文档处理的消息字段逃不掉演进,一开始没做版本管理,后来加字段的时候,消费端新旧版本同时在线,解析逻辑变得很别扭。带上 schema 版本,哪怕只有一个version字段,也能让发布顺序更从容。
第二,先建任务状态表再写消费逻辑。我一度以为 Kafka 不会重复,直到第一次 rebalance 把自己打脸。幂等表这个东西越早建越省心,它不仅能防重复,还能顺便提供处理状态查询,对业务方交代进度也很方便。
第三,第一天就把 Lag 监控和 DLQ 告警拉起来。这套系统稳定运行之后我最大的感触是,问题都是可以被提前发现的,区别在于你有没有把"发现问题的眼睛"装好。告警能在业务方投诉之前把问题暴露出来,比事后补救体面得多。
这套 Kafka 消费者与文档处理系统的技术方案,本质上没有用任何高深技巧,就是把最基础的队列语义、消息契约、幂等机制和监控体系老老实实组合在一起。如果你也在搭类似的异步处理链路,希望这份技术文档里的设计思路和踩坑记录,能帮你少折腾几个深夜。