0f68a64829
Deploy Examination / deploy (push) Successful in 10s
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
303 lines
36 KiB
JSON
303 lines
36 KiB
JSON
{
|
||
"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": []
|
||
}
|
||
]
|
||
} |