Kafka 中“精确一次(exactly-once)”语义解析
一、消息传递中的语义类型
首先,我们需要理解在消息传递中存在的三种语义:
- 最多一次(at-most-once):消息可能丢失,但绝不会重复。
- 至少一次(at-least-once):消息不会丢失,但可能重复。
- 精确一次(exactly-once):消息不会丢失,也不会重复,只会被处理一次。
二、通过银行转账场景说明
让我们通过一个银行转账的例子来说明:
(一)假设场景
假设有一个场景:用户 A 向用户 B 转账 100 元。这个操作被发送到 Kafka 作为一条消息。
(二)Kafka 0.11 版本之前情况(至少一次投递)
在 Kafka 0.11 版本之前,我们只能保证“至少一次”投递:
// 老版本的处理方式
public void processTransfer(TransferMessage message) {
try {
// 处理转账
transferMoney(message);
// 提交offset
consumer.commitSync();
} catch (Exception e) {
// 如果在提交offset前发生异常
// 重启后会重新消费这条消息
// 可能导致重复转账!
}
}
(三)引入事务特性后实现精确一次语义
Kafka 引入了事务特性后,可以实现精确一次语义:
public void processTransferWithTransaction() {
// 初始化事务型Producer
producer.initTransactions();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// 开启事务
producer.beginTransaction();
try {
// 1. 处理消息(转账操作)
TransferMessage message = parseMessage(record);
transferMoney(message);
// 2. 可能需要发送处理结果到另一个topic
ProducerRecord<String, String> result = createResult(message);
producer.send(result);
// 3. 提交消费位移
Map<TopicPartition, OffsetAndMetadata> offsets = getOffsets(record);
producer.sendOffsetsToTransaction(offsets, groupId);
// 4. 提交事务
producer.commitTransaction();
} catch (Exception e) {
// 如果发生异常,回滚事务
producer.abortTransaction();
// 整个转账操作会回滚,确保不会部分提交
}
}
}
}
三、实现精确一次语义的关键点
(一)原子性保证
转账操作、结果消息发送、offset 提交这三个操作要么都成功,要么都失败,不会出现部分成功的情况。
(二)事务协调器(Transaction Coordinator)
// 配置生产者启用事务
props.put("transactional.id", "transfer-processor-1");
// 配置消费者隔离级别
props.put("isolation.level", "read_committed");
事务协调器负责跨多个分区的事务原子性,确保:
- 要么所有分区都看到这个事务的结果。
- 要么都看不到这个事务的结果。
(三)幂等性生产者
// 启用幂等性
props.put("enable.idempotence", true);
即使在重试的情况下,同一条消息也只会被成功写入一次。
四、实际应用中的注意事项
(一)性能考虑
// 批量处理以提高性能
List<ConsumerRecord<String, String>> batch = new ArrayList<>();
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
batch.add(record);
if (batch.size() >= BATCH_SIZE) {
processRecordsInTransaction(batch);
batch.clear();
}
}
(二)错误处理
try {
processRecordsInTransaction(records);
} catch (TransactionException e) {
// 处理事务相关异常
handleTransactionError(e);
} catch (Exception e) {
// 处理其他异常
handleGeneralError(e);
}
(三)监控和告警
public void monitorTransactions() {
// 监控事务完成时间
long startTime = System.currentTimeMillis();
processRecordsInTransaction(records);
long endTime = System.currentTimeMillis();
// 如果事务执行时间过长,发送告警
if (endTime - startTime > TRANSACTION_TIMEOUT_MS) {
alertService.sendAlert("Transaction took too long: " + (endTime - startTime) + "ms");
}
}
五、关于精确一次语义的综合考量
理解精确一次语义很重要,但也要注意:
- 它会带来一定的性能开销。
- 需要合理配置事务超时时间。
- 要考虑业务是否真的需要这么强的保证。
六、根据业务需求选择语义
在实际应用中,应该根据业务需求来决定使用哪种语义:
- 对于日志收集这样的场景,可能 at-least-once 就足够了。
- 而对于金融交易这样的场景,才真正需要 exactly-once 语义。