Files
examination/topics/interview-prep/message-queue/code_reading.json
T
wonder 0f68a64829
Deploy Examination / deploy (push) Successful in 10s
feat: add interview-prep topic group with 250 questions (6 subtopics, 5 question types)
Subtopics:
- distributed-microservice: 45 questions (分布式微服务架构)
- message-queue: 45 questions (消息队列)
- k8s-observability: 45 questions (K8s与可观测性)
- go-java-concurrency: 45 questions (Go/Java并发模型)
- database-advanced: 35 questions (数据库进阶)
- ai-engineering: 35 questions (AI工程实践)

Question types: single_choice, true_false, fill_blank, short_answer, code_reading
2026-09-09 16:36:27 +08:00

303 lines
36 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"topic": "message-queue",
"type": "code_reading",
"schema_version": "1.0.0",
"generated": "2026-09-09T16:00:00+08:00",
"questions": [
{
"id": "cr-001",
"type": "code_reading",
"difficulty": 4,
"tags": [
"message-queue",
"kafka",
"consumer-group",
"partition"
],
"question": "阅读以下 Go 语言实现的 Kafka 消费者 Rebalance 监听器代码,分析其工作流程。",
"language": "go",
"code": "package kafka\n\nimport (\n \"log\"\n \"sync\"\n)\n\n// ConsumerRebalanceHandler 实现了 Kafka 消费者的 Rebalance 回调\n\ntype ConsumerRebalanceHandler struct {\n consumerGroup string\n partitions map[string][]int32 // topic -> partitions\n mu sync.RWMutex\n rebalanceCount int\n}\n\n// OnPartitionsAssigned 当分配到新分区时触发\nfunc (h *ConsumerRebalanceHandler) OnPartitionsAssigned(\n consumerGroup string,\n topicPartitions map[string][]int32,\n) {\n h.mu.Lock()\n defer h.mu.Unlock()\n \n // 1. 记录新分配的分区\n for topic, partitions := range topicPartitions {\n h.partitions[topic] = partitions\n }\n \n // 2. 重新初始化分区级别的消费位点缓存\n for topic, partitions := range topicPartitions {\n for _, p := range partitions {\n log.Printf(\"[ASSIGNED] group=%s topic=%s partition=%d\",\n consumerGroup, topic, p)\n }\n }\n \n h.rebalanceCount++\n log.Printf(\"Rebalance #%d 完成,共分配 %d 个分区\",\n h.rebalanceCount, countPartitions(topicPartitions))\n}\n\n// OnPartitionsRevoked 当分区被回收时触发\nfunc (h *ConsumerRebalanceHandler) OnPartitionsRevoked(\n consumerGroup string,\n topicPartitions map[string][]int32,\n) {\n h.mu.Lock()\n defer h.mu.Unlock()\n \n // 1. 先提交当前消费位点(确保不丢消息)\n for topic, partitions := range topicPartitions {\n for _, p := range partitions {\n log.Printf(\"[REVOKED] 提交位点: group=%s topic=%s partition=%d\",\n consumerGroup, topic, p)\n }\n }\n \n // 2. 清理分区状态\n for topic := range topicPartitions {\n delete(h.partitions, topic)\n }\n}\n\n// OnPartitionsLost 当分区不可用时触发(比 Revoked 更激进)\nfunc (h *ConsumerRebalanceHandler) OnPartitionsLost(\n consumerGroup string,\n topicPartitions map[string][]int32,\n) {\n // 不提交位点,直接清理\n h.mu.Lock()\n defer h.mu.Unlock()\n \n for topic := range topicPartitions {\n delete(h.partitions, topic)\n }\n log.Printf(\"[LOST] 分区丢失,未提交位点\")\n}",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "在 OnPartitionsRevoked 回调中,为什么要先提交消费位点再清理分区状态?",
"options": {
"A": "为了提高吞吐量,将位点提交和状态清理合并执行",
"B": "确保在分区被其他消费者接管前,当前已消费的进度不会丢失",
"C": "Kafka 协议要求在 revoke 时必须提交位点,否则会报错",
"D": "为了清理 Broker 端的位点缓存,释放存储空间"
},
"answer": "B",
"explanation": "Rebalance 的本质是将分区重新分配给其他消费者。如果不先提交位点就清理状态,新的消费者将从上次提交的位点开始消费,导致已处理的消息被重复消费。先提交位点可以保证新消费者从正确的位置继续,避免消息丢失或大量重复。Kafka 协议本身并不强制要求 revoke 时提交位点(选项 C 错误),这是一个最佳实践。"
},
{
"index": 2,
"type": "single_choice",
"question": "OnPartitionsLost 与 OnPartitionsRevoked 的核心区别是什么?",
"options": {
"A": "OnPartitionsLost 会触发更重的 Rebalance 流程",
"B": "OnPartitionsLost 不提交位点,适用于分区分配异常丢失的场景;OnPartitionsRevoked 是正常的 Rebalance 流程",
"C": "两者功能完全相同,只是调用时机不同",
"D": "OnPartitionsLost 是客户端行为,OnPartitionsRevoked 是服务端行为"
},
"answer": "B",
"explanation": "在 Kafka 的 Cooperative Rebalance 协议中,OnPartitionsLost 表示分区被异常剥夺(如 Consumer 挂了但 session 超时未过),此时无法安全提交位点(可能已在别处提交),所以直接清理状态。OnPartitionsRevoked 是正常的 Rebalance 流程,消费者有机会提交位点后再释放分区。代码中 OnPartitionsLost 也注释了「未提交位点」,印证了这一设计意图。"
},
{
"index": 3,
"type": "short_answer",
"question": "代码中使用 sync.RWMutex 而非 sync.Mutex 的原因是什么?请结合消费场景分析其性能优势。",
"answer": "OnPartitionsAssigned 和 OnPartitionsRevoked 只在 Rebalance 发生时被调用(低频),而消费者主线程会频繁读取 h.partitions 来判断某分区是否仍由自己负责(高频)。RWMutex 允许多个读操作并发执行,只有写操作(Rebalance 回调)才需要独占锁,因此用 RWMutex 可以避免高频读操作之间的锁竞争,显著提升正常消费路径的吞吐量。",
"keywords": [
"读写锁",
"高频读",
"低频写",
"Rebalance",
"消费主线程",
"并发"
],
"scoring_rubric": "答出「Rebalance 回调低频写、消费路径高频读」的核心场景差异得 3 分;提到 RWMutex 允许并发读得 2 分;提到避免锁竞争或性能提升得 1 分。满分 6 分,4 分及以上通过。"
}
],
"explanation": "本题考查 Kafka 消费者 Rebalance 机制的核心代码逻辑。Rebalance 是 Consumer Group 模型的关键机制,当消费者加入或离开组时,分区会被重新分配。理解三个回调(Assigned/Revoked/Lost)的调用时机和职责差异,是正确实现消费者的基础。重点掌握:(1) Revoked 时先提交位点防止消息重复;(2) Lost 与 Revoked 的语义差异;(3) 并发控制在消费路径中的应用。",
"source": null,
"related": []
},
{
"id": "cr-002",
"type": "code_reading",
"difficulty": 5,
"tags": [
"message-queue",
"rocketmq",
"transactional-message",
"durable"
],
"question": "阅读以下 Java 代码,分析 RocketMQ 事务消息的发送与回查机制实现。",
"language": "java",
"code": "package com.example.mq.transaction;\n\nimport org.apache.rocketmq.client.producer.*;\nimport org.apache.rocketmq.common.message.*;\n\npublic class TransactionMessageService {\n \n private final TransactionMQProducer producer;\n \n public TransactionMessageService(String namesrvAddr) {\n this.producer = new TransactionMQProducer(\"tx-producer-group\");\n this.producer.setNamesrvAddr(namesrvAddr);\n this.producer.setTransactionListener(new OrderTransactionListener());\n this.producer.setCheckThreadPool(5);\n this.producer.setCheckThreadPoolMaxSize(10);\n }\n \n // 发送事务消息\n public TransactionSendResult sendOrderMessage(Order order) {\n Message msg = new Message(\n \"ORDER_TOPIC\", // topic\n \"OrderTag\", // tag\n order.getOrderId(), // keys(用于回查时定位本地事务)\n order.toJson().getBytes() // body\n );\n \n // 附加业务上下文,供本地事务使用\n msg.putUserProperty(\"bizType\", \"ORDER_CREATE\");\n msg.putUserProperty(\"amount\", String.valueOf(order.getAmount()));\n \n // 发送半消息(half message),消费者此时不可见\n TransactionSendResult result = producer.sendMessageInTransaction(msg, order);\n \n log.info(\"事务消息发送结果: txId={}, localState={}, sendStatus={}\",\n result.getTransactionId(),\n result.getLocalTransactionState(),\n result.getSendStatus());\n \n return result;\n }\n}\n\n// 事务监听器:实现本地事务 + 回查逻辑\nclass OrderTransactionListener implements TransactionListener {\n \n private final OrderService orderService;\n \n @Override\n public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {\n Order order = (Order) arg;\n try {\n // 1. 执行本地事务(创建订单)\n orderService.createOrder(order);\n // 2. 本地事务成功,提交半消息 → 消费者可见\n return LocalTransactionState.COMMIT_MESSAGE;\n } catch (DuplicateKeyException e) {\n // 3. 订单已存在(幂等),直接提交\n return LocalTransactionState.COMMIT_MESSAGE;\n } catch (Exception e) {\n // 4. 本地事务失败,回滚半消息 → 消费者不可见\n return LocalTransactionState.ROLLBACK_MESSAGE;\n }\n }\n \n @Override\n public LocalTransactionState checkLocalTransaction(MessageExt msg) {\n String orderId = msg.getKeys();\n \n // 回查逻辑:根据 orderId 查询本地事务状态\n Order order = orderService.queryOrder(orderId);\n \n if (order == null) {\n // 查不到 → 本地事务可能未执行或已回滚\n return LocalTransactionState.UNKNOW;\n }\n \n if (\"CREATED\".equals(order.getStatus()) || \"COMPLETED\".equals(order.getStatus())) {\n return LocalTransactionState.COMMIT_MESSAGE;\n }\n \n if (\"CANCELLED\".equals(order.getStatus())) {\n return LocalTransactionState.ROLLBACK_MESSAGE;\n }\n \n return LocalTransactionState.UNKNOW;\n }\n}",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "当 executeLocalTransaction 抛出异常导致返回 ROLLBACK_MESSAGE 时,以下描述正确的是?",
"options": {
"A": "Broker 会将半消息标记为已回滚,消费者不会收到该消息,但消息仍会保留在 COMMIT_LOG 中",
"B": "Broker 会立即删除该半消息的物理存储",
"C": "Broker 会将该消息转发到死信队列供人工处理",
"D": "Broker 会自动重试 executeLocalTransaction 三次"
},
"answer": "A",
"explanation": "RocketMQ 事务消息回滚时,Broker 只是将半消息标记为回滚状态(在 HALF_TOPIC 中),使其对消费者不可见。消息的物理数据仍保留在 COMMIT_LOG 中(RocketMQ 采用追加写入,不支持物理删除),等待后续日志清理机制统一回收。选项 B 错误,RocketMQ 不做物理删除;选项 C 错误,回滚不是死信;选项 D 错误,回滚后不会重试本地事务。"
},
{
"index": 2,
"type": "short_answer",
"question": "当 checkLocalTransaction 返回 UNKNOW 时,RocketMQ Broker 的行为是什么?为什么要设计 UNKNOW 状态?",
"answer": "当 checkLocalTransaction 返回 UNKNOW 时,Broker 不会对半消息做任何操作(既不提交也不回滚),而是等待一段时间后再次发起回查。RocketMQ Broker 默认会以递增的时间间隔(如 60s, 120s, 240s...)最多回查 15 次。设计 UNKNOW 状态的原因是:本地事务可能正在执行中(如分布式事务协调器尚未完成),此时查询结果是不确定的,需要延迟再次确认。如果直接 COMMIT 或 ROLLBACK 可能导致数据不一致。",
"keywords": [
"UNKNOW",
"延迟回查",
"递增间隔",
"数据一致性",
"不确定性"
],
"scoring_rubric": "答出「Broker 会等待后再次回查」得 2 分;答出「递增间隔/多次回查」得 2 分;答出「UNKNOW 用于处理不确定状态/事务进行中」得 2 分。满分 6 分,4 分及以上通过。"
},
{
"index": 3,
"type": "single_choice",
"question": "代码中使用 order.getOrderId() 作为消息的 keys,这一设计对回查机制有什么关键作用?",
"options": {
"A": "仅用于消息检索,与回查机制无关",
"B": "回查时 Broker 将 keys 作为 MessageExt 的一部分下发,客户端通过 keys 定位对应的本地事务记录",
"C": "keys 会被 Broker 用于自动路由到正确的 Broker 节点",
"D": "keys 是消息的唯一标识,用于去重"
},
"answer": "B",
"explanation": "在 checkLocalTransaction 回调中,msg.getKeys() 返回的就是发送时设置的 keys 值(即 orderId)。回查的本质是 Broker 告诉 Producer「我有一个半消息不确定状态,请查一下你的本地事务」,Producer 需要一个标识来找到对应的本地记录。如果 keys 为空或不可唯一标识事务,回查时就无法判断本地事务是否成功。这正是代码中用 orderId 作为 keys 的核心原因。"
}
],
"explanation": "本题考查 RocketMQ 事务消息的完整生命周期:半消息发送 → executeLocalTransaction → 回查机制。事务消息的核心在于「本地事务 + 消息投递」的原子性保证。理解三种 LocalTransactionState(COMMIT/ROLLBACK/UNKNOW)的语义和 Broker 侧行为,以及 keys 在回查链路中的桥梁作用,是正确实现事务消息的关键。",
"source": null,
"related": []
},
{
"id": "cr-003",
"type": "code_reading",
"difficulty": 4,
"tags": [
"message-queue",
"dead-letter-queue",
"durable"
],
"question": "阅读以下 Go 语言实现的死信队列消费处理逻辑代码,分析其重试与兜底策略。",
"language": "go",
"code": "package mq\n\nimport (\n \"context\"\n \"encoding/json\"\n \"fmt\"\n \"log\"\n \"time\"\n)\n\n// DeadLetterHandler 处理死信队列中的消息\n\ntype DeadLetterHandler struct {\n retryProducer Producer\n alertService AlertService\n maxRetries int\n retryDelayBase time.Duration\n}\n\n// DeadLetterMessage 死信消息的结构\ntype DeadLetterMessage struct {\n OriginalTopic string `json:\"original_topic\"`\n OriginalBody []byte `json:\"original_body\"`\n ErrorInfo ErrorInfo `json:\"error_info\"`\n RetryCount int `json:\"retry_count\"`\n DLQTimestamp time.Time `json:\"dlq_timestamp\"`\n Metadata map[string]string `json:\"metadata\"`\n}\n\ntype ErrorInfo struct {\n ErrorCode string `json:\"error_code\"`\n Message string `json:\"message\"`\n}\n\n// HandleDeadLetter 处理单条死信消息\nfunc (h *DeadLetterHandler) HandleDeadLetter(ctx context.Context, msg *Message) error {\n var dlqMsg DeadLetterMessage\n if err := json.Unmarshal(msg.Body, &dlqMsg); err != nil {\n log.Printf(\"死信消息解析失败,丢弃: %v\", err)\n return nil // 解析失败的消息无法处理,确认消费避免无限循环\n }\n \n // 策略1:可重试的错误 → 重新投递到原 topic\n if h.isRetryable(dlqMsg.ErrorInfo.ErrorCode) {\n if dlqMsg.RetryCount < h.maxRetries {\n delay := h.calculateDelay(dlqMsg.RetryCount)\n \n err := h.retryProducer.SendWithDelay(\n ctx,\n dlqMsg.OriginalTopic,\n dlqMsg.OriginalBody,\n delay,\n )\n if err != nil {\n return fmt.Errorf(\"重试投递失败: %w\", err)\n }\n \n log.Printf(\"死信消息重试投递: topic=%s retry=%d/%d delay=%s\",\n dlqMsg.OriginalTopic, dlqMsg.RetryCount+1, h.maxRetries, delay)\n return nil\n }\n // 重试次数用尽,降级到告警\n log.Printf(\"重试次数用尽: topic=%s retry=%d\",\n dlqMsg.OriginalTopic, dlqMsg.RetryCount)\n }\n \n // 策略2:不可重试或重试用尽 → 告警 + 人工处理队列\n err := h.alertService.SendAlert(AlertPayload{\n Level: \"CRITICAL\",\n Title: \"死信消息需人工处理\",\n Detail: fmt.Sprintf(\"topic=%s error=%s retry=%d\",\n dlqMsg.OriginalTopic, dlqMsg.ErrorInfo.Message, dlqMsg.RetryCount),\n Timestamp: time.Now(),\n })\n if err != nil {\n log.Printf(\"告警发送失败: %v\", err)\n }\n \n // 将消息投递到人工处理队列\n return h.retryProducer.SendToQueue(ctx, \"MANUAL_DLQ_TOPIC\", msg.Body)\n}\n\nfunc (h *DeadLetterHandler) isRetryable(errorCode string) bool {\n retryableCodes := map[string]bool{\n \"NETWORK_TIMEOUT\": true,\n \"SERVICE_UNAVAILABLE\": true,\n \"RESOURCE_EXHAUSTED\": true,\n \"INVALID_INPUT\": false,\n \"PERMISSION_DENIED\": false,\n \"BUSINESS_RULE_VIOLATION\": false,\n }\n return retryableCodes[errorCode]\n}\n\nfunc (h *DeadLetterHandler) calculateDelay(retryCount int) time.Duration {\n // 指数退避:base * 2^retryCount,上限 30 分钟\n delay := h.retryDelayBase * time.Duration(1<<uint(retryCount))\n maxDelay := 30 * time.Minute\n if delay > maxDelay {\n delay = maxDelay\n }\n return delay\n}",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "当死信消息解析失败(JSON Unmarshal 返回错误)时,代码选择返回 nil 而非 error,这样做的原因是?",
"options": {
"A": "解析失败的消息由上游负责重试,当前层不需要关心",
"B": "返回 error 会导致消息被重新投递到死信队列,形成无限循环",
"C": "解析失败意味着消息格式损坏,返回 error 会让 Broker 丢弃该消息",
"D": "这是一种错误处理的简化写法,功能上没有区别"
},
"answer": "B",
"explanation": "在消息队列的消费模型中,消费失败(返回 error)通常会导致消息被重试或重新投递到死信队列。对于一条格式已损坏的死信消息,如果返回 error,它会被再次投递到死信队列,再次解析失败,形成无限循环。返回 nil 意味着「我已成功处理(虽然实际上是放弃)」,确认消费后消息不会被重投。这是处理「无法挽救的消息」的标准防御性编程模式。"
},
{
"index": 2,
"type": "single_choice",
"question": "calculateDelay 使用指数退避算法 `base * 2^retryCount` 并设置 30 分钟上限,这样设计的主要目的是?",
"options": {
"A": "减少 Broker 的存储压力",
"B": "避免对下游服务产生突发重试风暴,同时保证问题恢复后消息仍能被及时处理",
"C": "节省 Producer 的网络带宽",
"D": "满足 Kafka 的消息保留策略要求"
},
"answer": "B",
"explanation": "指数退避(Exponential Backoff)的核心目的是在「给下游服务恢复时间」和「问题修复后及时重试」之间取得平衡。如果以固定间隔重试,当下游服务不可用时会产生大量无效请求(重试风暴);如果退避时间过长,服务恢复后消息处理会有不必要的延迟。设置 30 分钟上限确保即使重试多次,延迟也不会过长,保证业务时效性。"
},
{
"index": 3,
"type": "short_answer",
"question": "请分析该死信处理逻辑的三层兜底策略,并说明每层分别解决什么问题。",
"answer": "三层兜底策略:(1) 可重试错误 + 未超限 → 指数退避重投原 topic,解决临时性故障(网络超时、服务不可用等)导致的消息消费失败,给下游恢复机会;(2) 可重试但重试用尽 / 不可重试错误 → 发送告警通知运维人员,解决需要人工介入的故障场景(如业务规则冲突、权限问题等);(3) 最终兜底 → 投递到 MANUAL_DLQ_TOPIC 人工处理队列,确保消息不会丢失,为人工修复后重新处理提供入口。",
"keywords": [
"重试投递",
"告警",
"人工处理队列",
"指数退避",
"三层兜底",
"不可重试错误"
],
"scoring_rubric": "答出三层结构(重试/告警/人工队列)各得 2 分;能说明每层解决的问题类型(临时故障/人工介入/最终兜底)得 2 分。满分 8 分,5 分及以上通过。"
}
],
"explanation": "本题考查死信队列消费处理的核心设计模式。死信消息是消费失败的最终归宿,如何合理处理决定了系统的可靠性和可运维性。关键设计点包括:(1) 解析失败的防御性处理(避免无限循环);(2) 可重试/不可重试错误的分类策略;(3) 指数退避重试机制;(4) 告警与人工处理的最终兜底。这套模式在 Kafka、RocketMQ、RabbitMQ 等消息系统中普遍适用。",
"source": null,
"related": []
},
{
"id": "cr-004",
"type": "code_reading",
"difficulty": 3,
"tags": [
"message-queue",
"partition",
"cluster"
],
"question": "阅读以下 Java 代码,分析 Kafka 生产者自定义分区路由策略的实现逻辑。",
"language": "java",
"code": "package com.example.mq.partitioner;\n\nimport org.apache.kafka.clients.producer.Partitioner;\nimport org.apache.kafka.common.Cluster;\nimport org.apache.kafka.common.PartitionInfo;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * 基于用户 ID 一致性哈希的分区策略\n * 保证同一用户的消息始终路由到同一分区,实现分区级顺序性\n */\npublic class ConsistentHashPartitioner implements Partitioner {\n \n private final Map<Integer, ConsistentHashRing> topicRings = new ConcurrentHashMap<>();\n private final int virtualNodes = 150; // 每个物理分区的虚拟节点数\n \n @Override\n public void configure(Map<String, ?> configs) {\n // 可从配置中读取虚拟节点数\n Object vnode = configs.get(\"consistent.hash.virtual.nodes\");\n if (vnode instanceof Integer) {\n // 此处省略赋值\n }\n }\n \n @Override\n public int partition(String topic, Object key, byte[] keyBytes,\n Object value, byte[] valueBytes, Cluster cluster) {\n List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);\n int numPartitions = partitions.size();\n \n if (key == null || keyBytes == null) {\n // 无 key 时使用默认轮询策略\n return defaultPartition(topic, numPartitions);\n }\n \n // 获取或创建该 topic 的哈希环\n ConsistentHashRing ring = topicRings.computeIfAbsent(topic,\n t -> buildHashRing(partitions));\n \n // 使用 murmur3 哈希确定路由\n int partition = ring.getNode(keyBytes);\n \n // 兜底:确保返回有效的分区编号\n return partition % numPartitions;\n }\n \n private ConsistentHashRing buildHashRing(List<PartitionInfo> partitions) {\n ConsistentHashRing ring = new ConsistentHashRing();\n for (PartitionInfo info : partitions) {\n ring.addPhysicalNode(info.partition(), virtualNodes);\n }\n return ring;\n }\n \n @Override\n public void close() {\n topicRings.clear();\n }\n \n private int defaultPartition(String topic, int numPartitions) {\n return Thread.currentThread().getId() % numPartitions;\n }\n}\n\n// 一致性哈希环实现\nclass ConsistentHashRing {\n private final TreeMap<Long, Integer> ring = new TreeMap<>();\n \n void addPhysicalNode(int nodeId, int virtualNodes) {\n for (int i = 0; i < virtualNodes; i++) {\n long hash = hash(nodeId + \"#\" + i);\n ring.put(hash, nodeId);\n }\n }\n \n int getNode(byte[] key) {\n long hash = hash(key);\n Map.Entry<Long, Integer> entry = ring.ceilingEntry(hash);\n if (entry == null) {\n entry = ring.firstEntry(); // 环形回绕\n }\n return entry.getValue();\n }\n \n private long hash(byte[] data) {\n // MurmurHash3 32-bit, 取绝对值确保非负\n return Math.abs(MurmurHash3.hash32(data));\n }\n}",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "当 Broker 发生扩缩容(分区数变化)时,一致性哈希环相比普通取模分区的优势是什么?",
"options": {
"A": "只有约 1/N 的消息需要重新路由到新分区,N 为分区总数,减少消息重平衡范围",
"B": "扩缩容时不会有任何消息路由变化,完全无缝",
"C": "扩缩容时所有消息都会重新路由,但延迟更低",
"D": "一致性哈希环不支持分区数变化"
},
"answer": "A",
"explanation": "一致性哈希的核心优势在于:当节点数从 N 变为 N+1 时,虚拟节点环上的映射关系只影响约 1/(N+1) 的数据路由。相比普通取模分区(hash % N)在 N 变化时几乎所有 key 的分区都可能改变,一致性哈希大大减少了路由变化的范围,降低了扩缩容对消费者的影响(已消费的位点无需大量重算)。选项 B 错误,任何哈希策略在分区变化时都无法完全避免路由变化。"
},
{
"index": 2,
"type": "single_choice",
"question": "代码中为每个物理分区创建 150 个虚拟节点,虚拟节点的作用是什么?",
"options": {
"A": "增加网络连接数,提高消息吞吐量",
"B": "使消息在各物理分区间的分布更加均匀,减少数据倾斜",
"C": "提供消息备份,一个分区不可用时自动切换到虚拟节点",
"D": "用于实现消息的延迟投递"
},
"answer": "B",
"explanation": "物理分区数较少时(如 3-6 个),直接用物理节点构建哈希环会导致数据分布不均匀(某些分区承担过多路由)。虚拟节点通过为每个物理分区创建多个哈希映射点,使哈希环上的节点分布更密集、更均匀,从而让消息负载均衡到各个物理分区。150 个虚拟节点是常用的经验值,可以在均匀性和查找效率之间取得平衡。"
},
{
"index": 3,
"type": "short_answer",
"question": "代码中 defaultPartition 使用 Thread.currentThread().getId() % numPartitions 作为无 key 消息的分区策略,这种方式存在什么潜在问题?",
"answer": "这种无 key 时的分区策略存在以下问题:(1) 线程 ID 不一定均匀分布,可能导致分区倾斜——某些线程 ID 哈希后集中在少数分区;(2) 线程池大小变化(如扩容后线程数增加)会导致同一批消息的路由分布发生变化;(3) 与消费者组的线程模型耦合,如果消费者端线程数变化,生产端的分区分布也受影响。更稳健的做法是使用 AtomicInteger 的自增计数器取模,确保严格的轮询均匀分布。",
"keywords": [
"线程ID不均匀",
"分区倾斜",
"线程池变化",
"轮询",
"AtomicInteger"
],
"scoring_rubric": "答出「线程ID分布不均导致分区倾斜」得 3 分;答出「线程数变化影响路由分布」得 2 分;提出改进建议(如 AtomicInteger 轮询)得 1 分。满分 6 分,4 分及以上通过。"
}
],
"explanation": "本题考查 Kafka 自定义分区策略的设计与实现。Kafka 的默认分区策略(轮询或 key hash)在很多场景下不够灵活,通过实现 Partitioner 接口可以自定义路由逻辑。一致性哈希分区策略是保证「同一业务实体的消息顺序性」的经典方案,同时在扩缩容时具有良好的稳定性。理解虚拟节点、哈希环回绕、无 key 降级策略等细节是正确实现的前提。",
"source": null,
"related": []
},
{
"id": "cr-005",
"type": "code_reading",
"difficulty": 3,
"tags": [
"message-queue",
"consumer-group",
"durable"
],
"question": "阅读以下 Go 语言实现的消息幂等消费去重逻辑代码,分析其去重机制。",
"language": "go",
"code": "package mq\n\nimport (\n \"context\"\n \"crypto/sha256\"\n \"encoding/hex\"\n \"fmt\"\n \"log\"\n \"time\"\n)\n\n// IdempotentConsumer 幂等消费者\ntype IdempotentConsumer struct {\n store DeduplicationStore\n processor MessageProcessor\n windowSize time.Duration // 去重时间窗口\n}\n\ntype DeduplicationStore interface {\n // SetIfAbsent 原子操作:若 key 不存在则设置并返回 true,否则返回 false\n SetIfAbsent(ctx context.Context, key string, ttl time.Duration) (bool, error)\n // Get 获取去重记录的存在时间\n Get(ctx context.Context, key string) (time.Time, error)\n}\n\n// ConsumeMessage 幂等消费入口\nfunc (c *IdempotentConsumer) ConsumeMessage(ctx context.Context, msg *Message) error {\n // 1. 生成幂等键\n dedupKey := c.buildDedupKey(msg)\n \n // 2. 原子性检查 + 写入\n firstTime, err := c.store.SetIfAbsent(ctx, dedupKey, c.windowSize)\n if err != nil {\n // 存储层故障时的降级策略:放行,不做去重\n log.Printf(\"去重存储故障,降级放行: key=%s err=%v\", dedupKey, err)\n return c.processor.Process(ctx, msg)\n }\n \n if !firstTime {\n // 3. 重复消息,跳过处理\n log.Printf(\"重复消息已跳过: key=%s msgId=%s\", dedupKey, msg.ID)\n return nil\n }\n \n // 4. 首次消息,正常处理\n err = c.processor.Process(ctx, msg)\n if err != nil {\n // 5. 处理失败时需要删除去重键,允许重试\n c.store.Delete(ctx, dedupKey)\n return fmt.Errorf(\"消息处理失败,已清除去重键: %w\", err)\n }\n \n log.Printf(\"消息处理成功: key=%s msgId=%s\", dedupKey, msg.ID)\n return nil\n}\n\n// buildDedupKey 构造去重键:组合业务维度,避免跨业务误去重\nfunc (c *IdempotentConsumer) buildDedupKey(msg *Message) string {\n // 方案A:使用消息内置的唯一 ID(最简单)\n if msg.ID != \"\" {\n return fmt.Sprintf(\"dedup:%s\", msg.ID)\n }\n // 方案B:使用业务字段哈希(适用于无全局唯一 ID 的场景)\n raw := fmt.Sprintf(\"%s:%s:%s:%d\",\n msg.Headers[\"biz_type\"],\n msg.Headers[\"order_id\"],\n msg.Headers[\"action\"],\n msg.Timestamp.Unix(),\n )\n hash := sha256.Sum256([]byte(raw))\n return fmt.Sprintf(\"dedup:hash:%s\", hex.EncodeToString(hash[:8]))\n}",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "当 SetIfAbsent 返回 false(非首次)时,代码直接返回 nil 不做任何处理。如果此时消费者需要「重新处理失败的消息」(即重试场景),这种设计会带来什么问题?",
"options": {
"A": "会导致消息被重复消费",
"B": "会导致重试的失败消息永远无法被重新处理,即「吞掉」重试消息",
"C": "没有任何问题,这是正确的去重逻辑",
"D": "会导致去重键永久占用存储空间"
},
"answer": "B",
"explanation": "这段代码的去重逻辑是「一次成功,永久跳过」。如果消息第一次处理失败(Process 返回 error),代码会删除去重键允许重试。但如果消息第一次处理成功后,由于某种原因需要重试处理(如业务层面的补偿),去重键仍然存在,重试消息会被当作重复消息跳过。这是去重逻辑的权衡——在「防重复」和「允许重试」之间需要根据业务场景选择不同的策略(如引入版本号或状态字段)。"
},
{
"index": 2,
"type": "single_choice",
"question": "当去重存储(Redis 等)故障时,代码选择「降级放行」而非「消费失败重试」,这样设计的理由是什么?",
"options": {
"A": "存储故障是永久性的,重试也没有意义",
"B": "保证消息处理的可用性优先于去重的严格性,避免因去重组件故障导致整体消费停滞",
"C": "Kafka 不支持消息重试",
"D": "降级放行的代码实现更简单"
},
"answer": "B",
"explanation": "分布式系统中,去重存储(如 Redis)是外部依赖,可能随时故障。如果此时消费失败重试,消息会被反复投递但始终无法处理,导致消费积压甚至消费者组崩溃。降级放行意味着「宁可重复消费,也不阻塞消费」,将可用性放在首位。重复消费的代价(如多扣一次款)可以通过业务层幂等(如数据库唯一约束)来兜底。这是分布式系统中「fail-open vs fail-close」的经典权衡。"
},
{
"index": 3,
"type": "short_answer",
"question": "代码中 buildDedupKey 提供了两种方案:消息 ID 和业务字段哈希。请分析各自的适用场景和优缺点。",
"answer": "方案 A(消息 ID):适用场景是消息系统提供了全局唯一 ID(如 Kafka 的 msg.Id 或 RocketMQ 的 MsgKey)。优点是简单可靠、无哈希冲突风险;缺点是依赖消息系统的 ID 唯一性保证,且不同消息系统或重试场景可能生成不同 ID,导致去重失效。\n方案 B(业务字段哈希):适用场景是消息没有全局唯一 ID,或需要按业务语义去重(如同一订单的同一操作)。优点是去重粒度可由业务控制,跨重试和消息系统保持一致;缺点是需要选择正确的业务字段组合,哈希存在理论上的碰撞风险,且时间精度(Unix秒级)可能影响去重精度。",
"keywords": [
"消息ID",
"业务字段哈希",
"全局唯一",
"去重粒度",
"哈希碰撞",
"适用场景"
],
"scoring_rubric": "方案 A 分析得当(适用场景 + 优缺点)得 3 分;方案 B 分析得当得 3 分;对比分析清晰得 1 分。满分 7 分,4 分及以上通过。"
}
],
"explanation": "本题考查消息幂等消费的核心设计模式。在分布式消息系统中,at-least-once 语义保证消息不丢但可能重复,幂等消费是处理重复消息的关键机制。核心设计点包括:(1) 原子性的去重检查(SetIfAbsent);(2) 去重键的设计(消息 ID vs 业务字段);(3) 存储故障的降级策略;(4) 处理失败时去重键的清理。在实际生产中,去重通常结合消息端(去重存储)和业务端(数据库唯一约束)双重保障。",
"source": null,
"related": []
}
]
}