一、💡 先说结论
这次面试官到底在考什么?
他不是只想听你背概念,而是在追问:遇到并发、重复、乱序、超时这些意外情况时,你怎么保证数据最后还是正确的?
这次一共问了三类问题:
- 数据库索引:你知不知道索引怎么分类、联合索引怎么生效。
- RocketMQ:消息为什么会乱序、为什么会重复、怎么保证幂等。
- RPC:调用超时以后,服务端到底有没有执行成功。
下面不按照教科书背定义,而是按照“面试官怎么问、他想听什么、你怎么回答”来理解。
二、🗂️ 数据库索引到底是什么
2.1 先用大白话理解索引
假设一本书有 1,000 页,你想找“RocketMQ”。
- 没有目录:从第一页开始一页页翻。
- 有目录:先在目录里找到对应页码,再直接翻过去。
数据库索引就像书的目录。
它能提高查询速度,但也有成本:
- 目录本身要占空间。
- 书的内容变了,目录也要跟着更新。
- 所以表中索引越多,新增、修改、删除数据的成本通常越高。
2.2 面试官问:数据库有哪些索引
这个问题容易说乱,是因为索引可以从两个角度分类:
按照索引包含几个字段分类:
- 单列索引:索引中只有一个字段。
- 联合索引:索引中包含两个或更多字段,也叫组合索引。
按照是否限制数据重复分类:
- 普通索引:主要用于加快查询,允许索引值重复。
- 唯一索引:除了加快查询,还要求索引值不能重复。
这两个分类角度可以互相组合:
| 字段数量 | 允许重复 | 不允许重复 |
|---|---|---|
| 一个字段 | 单列普通索引 | 单列唯一索引 |
| 多个字段 | 普通联合索引 | 联合唯一索引 |
另外还有主键索引:一张表只能有一个主键,主键本身具有唯一和非空约束。
如果从 InnoDB 的存储结构来看,还可以分为聚簇索引和二级索引;如果从数据结构来看,最常见的是 B+Tree。索引可以从不同角度分类,不能把它们当成同一个维度。
这样回答的好处是:先讲你真正用过的,再说明你知道索引还有其他分类方式。
2.3 面试官问:什么是唯一索引
假设用户表里有一个手机号字段:
UNIQUE INDEX uk_phone(phone)这是一个单列唯一索引,意思是同一个手机号不能被两个用户重复使用。
如果已经有手机号 13800000000,再插入一条相同手机号的数据,数据库就会拒绝。
它和主键的区别可以简单记成:
| 主键 | 唯一索引 |
|---|---|
| 一张表只能有一个 | 一张表可以有多个 |
不能为 NULL | NULL 的规则需要结合具体数据库说明 |
| 主要用于唯一标识一行数据 | 主要用于保证某个业务字段不重复 |
你可以这样回答:
唯一索引除了能提高查询速度,还能由数据库保证索引值不重复。它既可以只包含一个字段,也可以包含多个字段。例如手机号可以建立单列唯一索引,租户 ID 和员工编号可以建立联合唯一索引。并发情况下,唯一索引也是防止重复数据的重要兜底手段。
2.4 面试官问:联合索引和联合唯一索引有什么区别
联合索引只说明“这个索引里有多个字段”,它还可以继续分为:
- 普通联合索引:多个字段组成索引,但允许字段组合重复。
- 联合唯一索引:多个字段组成索引,而且要求字段组合不能重复。
面试语境
严格来说,联合唯一索引也属于联合索引。面试官把“联合索引”和“联合唯一索引”并列说时,前一个“联合索引”通常是在简称“普通联合索引”。
例如下面是普通联合索引:
INDEX idx_user_status_time(user_id, status, create_time)它允许出现两条完全相同的 (user_id, status, create_time) 组合,主要作用是提高查询速度。
下面是联合唯一索引:
UNIQUE INDEX uk_tenant_employee(tenant_id, employee_no)它限制的是两个字段的组合不能重复,不是要求每个字段单独唯一。例如:
| tenant_id | employee_no | 是否允许 |
|---|---|---|
| 1 | E001 | 第一次插入,允许 |
| 1 | E001 | 组合重复,不允许 |
| 1 | E002 | 组合不同,允许 |
| 2 | E001 | 组合不同,允许 |
所以,同一个租户内不能出现两个相同员工编号,但不同租户可以使用相同员工编号。
普通联合索引和联合唯一索引都要考虑字段顺序和最左匹配。下面继续使用普通联合索引说明字段顺序:
可以把它想象成一本通讯录,先按照姓排序,同一个姓里再按照名排序。
它首先按照 user_id 排序;user_id 相同时,再按照 status 排序;前两个字段也相同时,最后再按照 create_time 排序。
所以它擅长处理:
- 根据
user_id查询。 - 根据
user_id + status查询。 - 根据
user_id + status + create_time查询。
但如果直接根据 status 查询,就像跳过“姓”,直接在整本通讯录里找“名字叫强的人”,没有办法很好地利用原来的排列顺序。
这就是常说的最左匹配原则。
2.5 联合索引需要注意什么
面试时先记住下面四点就够了:
- 字段顺序很重要:常用查询能否从索引最左边开始匹配。
- 不是建了就一定使用:最终要通过
EXPLAIN看执行计划。 - 遇到范围查询要特别注意:后面的字段能否继续有效缩小扫描范围,需要结合查询和执行计划判断。
- 索引不是越多越好:查询可能变快,但写数据需要同时维护索引。
一段能直接说出口的回答
联合索引是多个字段组成的一个索引,例如
(user_id, status, create_time)。它会按照字段定义顺序排列,所以查询一般要满足最左匹配。设计时需要结合实际查询条件确定字段顺序,还要关注范围查询、覆盖索引和写入成本,最后通过EXPLAIN验证索引是否真的生效。
三、📨 为什么 A、B、C 不一定按顺序消费
3.1 你原来的理解错在哪里
你说“队列不是先进先出吗”,这个理解只差一个限定条件:
同一个队列内部可以有顺序,但多个队列之间没有统一的先后顺序。
可以把 RocketMQ 想象成超市收银台:
- 一个 Topic 是整个收银区。
- 一个 MessageQueue 是其中一条收银队伍。
- 每条队伍内部基本是先排队的人先结账。
- 但不同队伍的速度不同,后进另一条队的人完全可能先结完账。
如果 A、B、C 被分到不同队列,就可能发生:
队列 1:A → C
队列 2:B
实际完成顺序:A → C → B所以“先进先出”并不等于整个 Topic 下的全部消息都严格有序。
3.2 换成 aboss-sso 的真实场景
aboss-sso 中,账户发生变更后,会查询最新的 AccountDetailDTO,然后向账户变更 Topic 发送一份完整的账户数据。
消息中包含:
- 账户 ID
accId - 展示名称
displayName - 启用状态
enabled - 有效状态
isValid - 修改时间
gmtModified
假设同一个账号的 displayName 连续修改了三次:
第 1 次:displayName = A
第 2 次:displayName = B
第 3 次:displayName = C正常发送顺序是:
A(修改时间 10:00:01)
→ B(修改时间 10:00:02)
→ C(修改时间 10:00:03)但是下游实际收到的顺序可能是:
A(10:00:01)→ C(10:00:03)→ B(10:00:02)如果消费者每收到一条消息就直接覆盖数据库:
先写入 A → 再写入 C → 最后又被旧消息 B 覆盖最终值就会错误地变成 B,而不是最新的 C。
3.3 aboss-sso 当前代码能保证顺序吗
从当前项目能看到的代码来看:
AbsAccountChangeCmdExe.publish()查询账户最新详情。AccountChangeMessage.publish()把整个AccountDetailDTO序列化后发送。- 当前发送代码中没有明显看到把
accId设置成MessageGroup或ShardingKey。 AccountDetailDTO中已经有gmtModified,但没有专门的递增版本号。
因此,仅根据当前代码,不能认为同一账号的多次变更一定会按照 A、B、C 的顺序被下游处理。具体是否由 SOFAMQ 的外部配置保证顺序,还需要继续确认平台配置和下游消费者代码。
3.4 如果只关心最终结果是 C
如果 A、B、C 只是同一个字段的新旧值,业务只关心最终结果,那么最实用的办法不是死等每条消息按顺序到达,而是:
比较消息中的
gmtModified,消费者只允许修改时间更新的消息覆盖当前数据。
消费过程如下:
| 收到的消息 | 下游已保存的修改时间 | 怎么处理 |
|---|---|---|
| A,10:00:01 | 无 | 第一次收到,写入 A |
| C,10:00:03 | 10:00:01 | 消息更新,写入 C |
| B,10:00:02 | 10:00:03 | 消息更旧,直接丢弃 |
最后数据库仍然是 C。
关键点是,修改时间判断和字段更新要放在同一个数据库操作中,避免两个消费者同时判断造成并发问题。思路类似:
UPDATE account_sync
SET display_name = 'C',
source_gmt_modified = '2026-09-29 10:00:03'
WHERE acc_id = '1001'
AND (source_gmt_modified IS NULL
OR source_gmt_modified < '2026-09-29 10:00:03');如果受影响行数为 0,说明下游已经处理过相同时间或更新的数据,这条消息就不再覆盖。
这个方案和 aboss-sso 比较贴合,因为现有的 AccountDetailDTO 已经携带 gmtModified,不需要先增加新字段。
修改时间方案的边界
gmtModified最好来自源数据库,而不是由不同应用服务器各自生成。如果两次更新可能落在同一个时间精度内,它们的修改时间可能相同。这种要求很高的场景,数据库递增的version会比时间更可靠。
3.5 如果 A、B、C 每一步都必须执行
有些场景不能只保留最终值。例如:
A:冻结账户
B:回收权限
C:归档账户这三步都有独立副作用,不能因为 C 先到了就跳过 B。
这种情况下需要同时做两层保护:
- 生产者使用
accId作为顺序键,让同一账号的消息进入同一个队列。 - 消费者使用顺序消费,不能再交给多个线程并发执行。
- 消息还需要携带递增版本号或事件序号,消费者检查序号是否连续。
- 当前版本是 1,却收到版本 3,说明版本 2 还没处理,版本 3 应暂存或重试。
- 只有收到版本 2 并处理完成后,才能继续处理版本 3。
也就是说:
- 只关心最终状态:通过
gmtModified丢弃旧消息,保证最终是 C。 - 每个过程都必须执行:仅靠修改时间不够,还要通过版本号或事件序号发现断档,等待或重试,不能越过 B。
3.6 aboss-sso 可以采用的方案
先固定一个具体账号:
accId = 1001这个账号的 displayName 连续发生三次修改:
10:00:01 改成 A
10:00:02 改成 B
10:00:03 改成 C这里说的“同一业务对象”,其实没有那么复杂,就是:
这三条消息修改的都是数据库中
accId=1001的同一个账号。
如果另外一个账号 accId=2001 也发生修改,那就是另一个业务对象。
第一层保护:让同一个账号的消息排进同一条队伍
一个 MQ Topic 里面通常不只有一条队列,可以把它想象成超市开了多条收银通道:
账户变更 Topic
├── 队列 0
├── 队列 1
└── 队列 2如果不指定规则,三条消息可能被分散到不同队列:
A → 队列 0
B → 队列 1
C → 队列 2不同队列的处理速度不同,因此 C 完全可能比 B 先处理。
“把 accId 作为顺序键”的意思是:发送消息时,不只传消息内容,还要额外把账号 ID 作为 ShardingKey 传给顺序消息生产者。
在 SOFAMQ 中,真正起作用的是下面两件事:
- 使用
OrderProducer,不能继续使用普通的Producer。 - 发送时调用
orderProducer.send(message, accId),第二个参数accId就是ShardingKey。
核心写法是:
import io.openmessaging.api.order.OrderProducer;
// createOrderProducer:创建专门发送顺序消息的生产者。
OrderProducer orderProducer = accessPoint.createOrderProducer(properties);
Message message = new Message(
topic,
tag,
JSON.toJSONString(accountInfoDTO).getBytes(StandardCharsets.UTF_8)
);
// 同一个账号每次都传相同的 accId。
// send 的第二个参数就是 ShardingKey,SOFAMQ 会根据它选择分区或队列。
String shardingKey = accountInfoDTO.getAccId();
orderProducer.send(message, shardingKey);aboss-sso 当前代码不是这种写法:
// 当前创建的是普通 Producer。
Producer producer = accessPoint.createProducer(properties);
// 当前发送时也没有传入 accId。
getProducer().send(message);因此,当前的 getProducer().send(message) 不能靠 accId 把同一账号的消息固定到同一个队列。
如果要改造,不能只在消息正文中增加一个 accId 字段。消息正文中的 accId 只是给业务代码看的,MQ 不会自动拿它选择队列。必须把 accId 作为 send 方法的第二个参数传入。
MQ 收到 ShardingKey 后,可以把内部规则简单理解为:
根据 accId 计算应该进入哪一条队列因为 A、B、C 的 accId 都是 1001,计算结果相同,所以它们都会进入同一条队列:
队列 1:A → B → C另一个账号 accId=2001 可以被分到其他队列:
队列 0:账号 2001 的消息
队列 1:账号 1001 的 A → B → C
队列 2:其他账号的消息这样做不是把所有账户的消息都塞进同一个队列,而是只要求“同一个账号”的消息进入同一条队伍。不同账号仍然可以同时处理。
但生产者这样设置还没有结束,完整改造需要三部分配套:
| 位置 | 需要做什么 | 大白话解释 |
|---|---|---|
| Topic | 确认平台支持并按顺序消息使用 | 先准备一条支持排队的通道 |
| 生产者 | 使用 OrderProducer,调用 send(message, accId) | 把同一账号送进同一条队伍 |
| 消费者 | 使用 OrderConsumer 顺序消费 | 按队伍顺序一个一个处理 |
消费者端对应的是 OrderConsumer。如果消费者收到消息后又把 A、B、C 交给三个线程并发执行,仍然可能变成 C 比 B 先完成。
当前项目的改造点
ProducerConfig当前创建的是普通Producer,AccountChangeMessage的getProducer()返回类型也是普通Producer。因此不能只把send(message)改成两个参数就结束,还需要为账户变更消息单独创建和管理OrderProducer,下游也要确认使用OrderConsumer。
第二层保护:即使 B 最后到,也不允许它覆盖 C
只依赖消息顺序还不够稳妥。重试、网络抖动或配置问题仍可能让旧消息晚到,所以消费者还要比较消息中的 gmtModified。
下游数据库在保存账号信息时,再多保存一个“我处理过的最新修改时间”:
accId = 1001
displayName = C
sourceGmtModified = 10:00:03消费者按 A、C、B 的顺序收到消息时:
- 收到 A,时间是
10:00:01,数据库还没有时间,写入 A。 - 收到 C,时间是
10:00:03,比数据库中的10:00:01更新,写入 C。 - 最后收到 B,时间是
10:00:02,比数据库中的10:00:03更旧,不再更新。
最终结果仍然是 C。
数据库更新可以理解成一句话:
只有“这条消息的修改时间比数据库里记录的时间更新”,才允许覆盖数据。
对应的思路类似:
UPDATE account_sync
SET display_name = 'B',
source_gmt_modified = '2026-09-29 10:00:02'
WHERE acc_id = '1001'
AND source_gmt_modified < '2026-09-29 10:00:02';此时数据库已经保存了 C 的 10:00:03,条件 10:00:03 < 10:00:02 不成立,所以更新行数是 0,B 无法覆盖 C。
这里说的“原子更新”,就是让数据库在一条 SQL 里同时完成“比较时间”和“更新数据”。不要先查询时间、再在 Java 中判断、最后执行更新,因为这三个步骤之间可能又有其他线程修改数据。
为什么还要提 version
aboss-sso 现有消息已经有 gmtModified,所以面试时先讲修改时间最贴合项目。
不过,如果 A 和 B 在极短时间内完成,数据库记录的时间精度不够,它们有可能得到相同的修改时间。这时就无法判断谁更新。更严格的系统会再增加一个每次修改都加一的数字:
A:version=1
B:version=2
C:version=3它的判断方式和修改时间完全一样,只是数字先后更加明确。
还有一个更简单的备选方案
消费者可以不直接使用消息里的 A、B、C。收到消息后,只把它当成一句提醒:“账号 1001 发生变化了。”然后根据 accId=1001 重新查询 aboss-sso 的数据库或查询接口。
因为源数据库中的最终值已经是 C,所以即使最后收到的是旧消息 B,重新查询得到的仍然是 C。这个方案更容易理解,但每消费一条消息都要再查询一次,调用量会更大。
一段能直接说出口的回答
在我们的 aboss-sso 中,同一个账号可能连续被改成 A、B、C。这里的同一个业务对象就是同一个 accId。为了减少乱序,我会在发送时用 accId 选择队列,让同一个账号的消息都排进同一条队伍,再由消费者串行处理。同时,下游会记录已经处理过的最新 gmtModified。假如消息按 A、C、B 到达,处理完 C 后数据库保存的时间是 10:00:03;B 的时间只有 10:00:02,比当前数据旧,所以数据库不会让 B 覆盖 C。这样既从 MQ 层尽量保证顺序,也从业务层防止旧消息覆盖新数据。
3.7 一个 MQ 到底有几个队列
先把三个概念分开:
MQ 实例或集群
└── Topic:例如账户变更 Topic
├── Queue 0
├── Queue 1
├── Queue 2
└── ……平时大家说“把消息发到 MQ”,真正发送时一定会指定一个 Topic。消息最终存放在这个 Topic 下面的某一个 Queue 中。
因此,准确说法是:
一个 Topic 至少有一个 Queue,也可以有多个 Queue;具体数量不是固定的,要看 Topic 创建时的平台配置和 RocketMQ 版本。
例如,假设账户变更 Topic 有 8 个队列:
Queue 0
Queue 1
Queue 2
Queue 3
Queue 4
Queue 5
Queue 6
Queue 7发送顺序消息时,SOFAMQ 会根据 ShardingKey 从这 8 个队列中选择一个。可以把内部过程简单理解为:
accId → 计算 → 得到一个队列编号同一个 accId=1001 每次计算结果相同,所以它的 A、B、C 都会进入同一个队列:
账号 1001 的 A、B、C → Queue 3另一个账号可能进入其他队列:
账号 2001 的消息 → Queue 6也可能恰好同样进入 Queue 3,这并不冲突。顺序要求只是保证同一个 ShardingKey 的消息有序,不要求每个账号独占一条队列。
队列数量主要影响并发能力:
- 队列少:同时处理消息的能力较低,但结构简单。
- 队列多:可以让更多消费者并行处理,但会增加元数据和客户端负担。
- 不是账号有多少个,就需要创建多少个队列。
开源 RocketMQ 4.x 创建 Topic 时可以配置读写队列数,官方命令行工具的默认示例是 8 个读队列和 8 个写队列。但这不代表 aboss-sso 当前 Topic 一定有 8 个队列。
aboss-sso 的配置文件中只能看到 Topic 名:
spring.sofamq.account.change.topic=TP_CIC_MIDABOSS_SSO_ACCOUNT_MODIFY代码里没有配置队列数量,所以需要到 SOFAMQ 管理平台查看该 Topic 的详情,或者向 MQ 平台管理员确认。
面试时怎么回答
一个 MQ 集群中可以有很多 Topic,一个 Topic 中又包含一个或多个 Queue,队列数不是固定的。创建 Topic 时可以由系统分配或人工配置,具体取决于 RocketMQ 版本和平台。队列数越多,并发能力通常越高。顺序消息会根据 ShardingKey 把同一个业务对象的消息路由到同一个队列,但不是一个业务对象独占一个队列。
四、🔁 为什么一条消息可能消费两次
4.1 先理解重复消费是怎么来的
消费者处理一条消息,大致会经历:
收到消息 → 修改数据库 → 告诉 RocketMQ“处理成功”假设数据库已经修改成功,但消费者还没来得及告诉 RocketMQ,程序突然宕机了。
RocketMQ 不知道刚才到底有没有处理成功。为了避免丢消息,它只能把消息再次投递。
于是同一条消息就可能被消费两次。
这不是 RocketMQ 故意出错,而是在“宁可重复,也尽量不要丢失消息”的可靠性设计下,业务系统必须面对的情况。
4.2 我的实际案例:人工做消息补偿,连续点几次怎么办
我在项目里会人工做消息补偿。具体场景可以这样描述:
账号 1001 发生了一次变更
↓
正常发送的 MQ 消息没有被下游正确处理
↓
系统中留下了一条“待补偿”或“处理失败”的记录
↓
我在页面上找到这条失败记录,点击“消息补偿”按钮
↓
后端重新发送一条账号变更消息,让下游再处理一次如果我担心没有生效,连续点击了三次补偿按钮,那么后端很可能真的发送三条消息:
第 1 次点击 → 发送消息 M1
第 2 次点击 → 发送消息 M2
第 3 次点击 → 发送消息 M3虽然 M1、M2、M3 的消息 ID 不同,但它们做的是同一件事:
都是在补偿账号 1001 的同一次失败变更。
所以这个案例更准确的说法不是“RocketMQ 把一条消息投递了三次”,而是:
我连续点击人工补偿,业务系统主动发送了三条内容相同或作用相同的新消息。
这里要先区分两种“重复”:
| 情况 | 实际发生了什么 |
|---|---|
| MQ 重复投递 | 生产者只发送一次,但同一条消息因为超时、确认丢失等原因被再次投递 |
| 重复点击补偿按钮 | 用户点了多次,生产者真的发送了多条消息,这些消息的 msgId 可能都不相同 |
我的人工补偿场景属于第二种。对 RocketMQ 来说,这三条消息都是正常发送的新消息,它并不知道它们其实是在补偿同一件事。
因此,不能只依赖 MQ 的 msgId 去重,而要给“这一次失败”一个固定编号。这个固定编号在技术上叫做幂等键,在这里也可以叫 compensateKey。
最容易理解的做法是:系统第一次发现消息处理失败时,就生成一条失败记录,例如:
失败记录 ID:F10001
账号:1001
失败原因:下游处理失败
状态:待补偿以后不管我点击几次“消息补偿”,前端都把同一个失败记录 ID F10001 传给后端,后端发送的每条消息也都携带 F10001:
第 1 次点击 → msgId=M1,compensateKey=F10001
第 2 次点击 → msgId=M2,compensateKey=F10001
第 3 次点击 → msgId=M3,compensateKey=F10001这里真正用来判断重复的是 F10001,不是 M1、M2、M3。
如果项目里暂时没有失败记录 ID,也可以使用“补偿类型 + 业务 ID + 原始数据版本或修改时间”组合生成固定编号。
假设账号 1001 在 10:00:03 发生了一次同步失败,可以设计:
compensateKey = ACCOUNT_SYNC:1001:20260929100003它表达的是:
补偿类型:ACCOUNT_SYNC
业务对象:账号 1001
需要补偿的那次变更:2026-09-29 10:00:03用户连续点击五次按钮时,虽然会产生五条物理消息,但五条消息必须使用同一个 compensateKey:
消息 1:msgId=M1,compensateKey=ACCOUNT_SYNC:1001:20260929100003
消息 2:msgId=M2,compensateKey=ACCOUNT_SYNC:1001:20260929100003
消息 3:msgId=M3,compensateKey=ACCOUNT_SYNC:1001:20260929100003
消息 4:msgId=M4,compensateKey=ACCOUNT_SYNC:1001:20260929100003
消息 5:msgId=M5,compensateKey=ACCOUNT_SYNC:1001:20260929100003msgId 不同没有关系,消费者根据相同的 compensateKey 判断它们是在补偿同一件事。
防重最好做两层。
第一层:点击按钮时,尽量不要重复发送
如果页面展示的是一条已经存在的失败记录,那么点击补偿时,不要每点一次就创建一个新的补偿编号。后端应始终使用这条失败记录原来的 ID,例如 F10001。
补偿接口可以把 F10001 写入补偿任务表,并对 compensate_key 建立唯一索引:
compensate_key status
F10001 PROCESSING连续点击五次时:
第 1 次:F10001 创建补偿任务成功,发送消息
第 2~5 次:发现 F10001 已有补偿任务,返回原任务,不再重复创建如果已有任务状态是 FAILED,再次点击时可以重新发送同一个任务,但仍然使用原来的 compensateKey,不能创建一条新的补偿业务。生产消息是否成功也应记录在任务状态中,避免出现任务已经创建、消息却没有成功发出的情况。
前端把按钮置灰、防抖,只能改善用户体验,不能作为最终保证。因为用户可能刷新页面、同时打开多个页面,接口也可能被其他系统直接调用。
第二层:即使真的发出了五条消息,消费者也只能执行一次
消费者收到消息后,直接尝试把 compensateKey 插入消费记录表,该字段有唯一索引:
消费者 1:插入 compensateKey → 成功 → 获得处理资格
消费者 2:插入 compensateKey → 唯一键冲突 → 不再执行补偿
消费者 3:插入 compensateKey → 唯一键冲突 → 不再执行补偿
消费者 4:插入 compensateKey → 唯一键冲突 → 不再执行补偿
消费者 5:插入 compensateKey → 唯一键冲突 → 不再执行补偿插入消费记录和真正的补偿操作要放在同一个数据库事务中:
插入消费记录 + 执行补偿业务 + 标记成功三步一起成功才提交。如果补偿过程中失败,事务一起回滚,下次仍然可以使用同一个 compensateKey 重试。
如果系统需要保留失败记录,不删除任务,也不要换一个新的补偿键。可以把原任务状态从 FAILED 改回 PROCESSING,继续重试同一条补偿任务。
新的业务变化为什么还能正常补偿
如果账号 1001 后来在 10:05:00 又发生了一次新的同步失败,它应该使用新的补偿键:
第一次失败:ACCOUNT_SYNC:1001:20260929100003
第二次失败:ACCOUNT_SYNC:1001:20260929100500这样既能挡住同一次失败的重复点击,又不会把后续真正的新补偿误认为重复。
整个过程可以记成:
用户连续点击五次
↓
五条消息可能有不同 msgId,但使用相同 compensateKey
↓
消费者用 compensateKey 抢处理资格
↓
只有一个消费者成功,其余全部跳过面试时怎么回答
我们项目中有人工消息补偿。如果某条消息处理失败,我会在页面上找到失败记录,再点击补偿按钮重新发消息。如果连续点击多次,实际上会产生多条不同 msgId 的新消息,所以不能使用 msgId 判断是不是同一次补偿。我会把原始失败记录 ID 作为固定的 compensateKey,每次点击和每次重新发送都继续使用这个 ID。补偿接口先防止同一个失败记录重复创建任务;消费者再通过 compensateKey 的唯一索引抢处理资格,只有插入成功的消费者才执行业务。幂等记录和补偿操作放在同一个事务中。这样即使我点了五次,业务也只会成功执行一次。
4.3 什么叫幂等
幂等听起来很抽象,其实意思很简单:
同一个操作执行一次和执行多次,最终结果都一样。
比如“把订单状态设置为已支付”:
- 执行一次,状态是已支付。
- 再执行一次,状态还是已支付。
但“账户余额减 100 元”不是天然幂等:
- 执行一次,减 100 元。
- 执行两次,减 200 元。
所以扣款、创建订单、发优惠券等操作必须额外设计幂等保护。
4.4 余额减 100 元,怎么避免重复扣款
假设系统要从账号 1001 的余额中扣除 100 元。
首先要为“这一次扣款”生成一个不会变化的业务流水号:
deductNo = DK202609290001
accountId = 1001
amount = 100这个 deductNo 表示的是“哪一笔扣款”,不是第几次请求。
第一次调用、接口超时后的重试、MQ 重复投递,都必须继续使用同一个 deductNo。如果每次重试都生成新的流水号,系统会把它们当成不同的扣款,幂等就失效了。
数据库准备一张扣款记录表,并让 deduct_no 不能重复:
扣款记录表
-------------------------------------------------
deduct_no account_id amount status
DK202609290001 1001 100 SUCCESS可以把这张表理解成一本扣款登记簿。每次扣钱之前,必须先登记这笔流水;同一个流水号只能登记一次。
下面的 SQL 只是讲解用的示例,不会在数据库中执行:
-- 这个唯一索引保证同一个扣款流水号只能出现一次。
CREATE UNIQUE INDEX uk_deduct_no
ON account_deduction_record(deduct_no);第一次收到扣款消息时,在同一个数据库事务中执行:
START TRANSACTION;
-- 第一步:登记这笔扣款。
-- deduct_no 有唯一索引,重复消息无法再次插入相同流水号。
INSERT INTO account_deduction_record
(deduct_no, account_id, amount, status)
VALUES
('DK202609290001', '1001', 100, 'PROCESSING');
-- 第二步:真正扣余额。
-- balance >= 100 可以避免把余额扣成负数。
UPDATE account_balance
SET balance = balance - 100
WHERE account_id = '1001'
AND balance >= 100;
-- Java 代码需要检查上面的 UPDATE 是否成功更新了 1 行。
-- 如果更新了 0 行,说明余额不足或账号不存在,整个事务回滚。
-- 第三步:记录这笔扣款已经成功。
UPDATE account_deduction_record
SET status = 'SUCCESS'
WHERE deduct_no = 'DK202609290001';
COMMIT;为什么扣款记录和余额修改必须放在同一个事务中?
- 如果只登记成功但没有扣款成功,数据会不一致。
- 如果已经扣款但没有登记成功,重复消息到达时可能再次扣款。
- 放在同一个事务中,要么两件事都成功,要么两件事都不生效。
现在假设 MQ 又把同一条消息投递了一次:
deductNo 仍然是 DK202609290001消费者再次插入扣款记录时,会触发 deduct_no 唯一索引冲突。系统由此知道:这笔扣款以前已经处理过。
此时不要再次执行余额扣减,而是查询原来的扣款记录:
- 原记录是
SUCCESS:直接返回“扣款成功”,并向 MQ 返回消费成功。 - 原记录是明确的失败结果:返回第一次的失败结果,不要重复扣款。
- 如果采用了“处理中”状态并且查到
PROCESSING:稍后查询结果,不要立刻再扣一次。
如果第一次事务执行失败并回滚,那么扣款记录和余额修改都会撤销。下一次消费可以重新插入流水并重新处理。
整个过程可以记成:
收到扣款消息
↓
用 deductNo 登记扣款流水
↓
登记成功 → 第一次处理 → 扣余额
登记失败 → 流水已存在 → 不再扣余额,返回第一次的结果假如第一次消费已经把余额扣掉,但还没来得及向 MQ 返回成功,MQ 会再次投递。第二次消费会被相同 deductNo 挡住,因此余额仍然只减 100 元。
面试时怎么回答
余额扣减不是天然幂等,我会让上游为每一笔扣款生成唯一且重试时不变的业务流水号。消费时先把流水号写入扣款记录表,该字段建立唯一索引,然后在同一个数据库事务中扣减余额并把流水状态改成成功。重复消息再次到达时,插入相同流水号会发生唯一键冲突,这时直接查询并返回第一次的处理结果,不再扣余额。这样即使 MQ 重复投递,余额也只会扣一次。
4.5 你说“先校验,再插入”为什么还不够
你的方向没有错,但如果只有“先查询数据库有没有”,会遇到并发问题:
消费者 1:查询 A,不存在
消费者 2:查询 A,也不存在
消费者 1:插入 A
消费者 2:也插入 A两个消费者几乎同时查询时,都认为数据不存在。
所以“先查再插”不是一个不可分割的操作,单靠它挡不住并发。
正确思路是:
不要先查询再决定谁可以插入,而是让两个消费者直接插入同一个唯一业务键,由数据库决定谁成功。
假设两次收到的其实是同一笔业务,业务唯一键都是:
bizNo = A消费记录表对 biz_no 建立唯一索引。两个消费者同时处理时:
消费者 1:插入 bizNo=A → 成功
消费者 2:插入 bizNo=A → 唯一键冲突数据库会保证只有一个消费者插入成功。插入成功的消费者才有资格继续执行业务;发生唯一键冲突的消费者知道这笔业务已经被别人处理,不再重复新增。
完整过程如下:
消费者收到消息
↓
直接插入业务唯一键,不先查询
↓
插入成功 唯一键冲突
↓ ↓
第一次处理,继续新增业务数据 重复消息,不再新增
↓ ↓
提交事务 返回第一次的处理结果例如下面两步要放在同一个数据库事务中:
START TRANSACTION;
-- 第一步:抢占这笔业务的处理资格。
-- biz_no 有唯一索引,只会有一个消费者插入成功。
INSERT INTO mq_consume_record(biz_no, status)
VALUES ('A', 'PROCESSING');
-- 第二步:真正新增业务数据。
INSERT INTO business_data(biz_no, content)
VALUES ('A', '业务内容');
-- 第三步:记录处理成功。
UPDATE mq_consume_record
SET status = 'SUCCESS'
WHERE biz_no = 'A';
COMMIT;上面的 SQL 只是讲解示例,不会在数据库中执行。
为什么要放在同一个事务里?
- 消费记录插入成功,但业务新增失败:事务回滚,消费记录也一起撤销,之后还能重试。
- 业务新增成功:消费记录也同时提交,重复消息以后就会被唯一索引挡住。
如果业务表本身已经有天然唯一字段,例如订单号,并且重复消息只会重复创建这一条业务数据,也可以直接在业务表的订单号上建立唯一索引。第一次插入成功,第二次插入发生唯一键冲突,再根据订单号查询并返回已有结果。
不要只用 Java 的 synchronized 解决,因为服务部署了多个实例时,每个实例都有自己的锁,彼此挡不住。分布式锁可以作为辅助,但数据库唯一约束仍然是更可靠的最终防线。
面试时怎么回答
先查询再插入存在并发窗口,两个消费者可能同时查到不存在。我的做法是使用订单号或业务流水号作为幂等键,在消费记录表或业务表上建立唯一索引。消费者不先查询,而是直接尝试插入幂等键,只有插入成功的消费者继续执行业务;另一个消费者遇到唯一键冲突后直接返回已有结果。幂等记录和业务新增放在同一个事务中,这样要么一起成功,要么一起回滚。
4.6 唯一索引是不是简单粗暴的方案
唯一索引不是错误方案,反而是非常重要的数据库兜底。
真正的问题是:回答不能只停留在“我建一个唯一索引”。你还应该说明:到底拿什么字段作为唯一标识,以及重复请求来了以后怎么返回结果。
例如消费“创建订单”消息,可以使用业务订单号 order_no:
UNIQUE INDEX uk_order_no(order_no)完整思路是:
- 每条消息带一个稳定的业务唯一键,例如订单号、支付流水号。
- 消费记录表对这个业务键建立唯一约束。
- 写消费记录和写业务数据放在同一个数据库事务中。
- 如果唯一键冲突,说明以前处理过,不再重复创建。
- 业务处理完成后,再告诉 RocketMQ 消费成功。
为什么不建议只依赖消息的
msgId同一个业务操作重新发送时,可能产生新的消息 ID。因此,比起只使用
msgId,订单号、业务流水号等业务唯一键通常更稳定。
4.7 面试时怎么回答重复消费
RocketMQ 可能因为消费超时、程序宕机或者没有及时返回确认而重新投递,所以消费者必须保证幂等。我会使用订单号或业务流水号作为幂等键,在消费记录表和业务表上建立唯一约束,并把幂等记录与业务操作放在同一个事务中。重复消息再次到达时,根据已有记录直接跳过或返回第一次的结果。唯一索引不是全部方案,但它是并发情况下非常重要的最终兜底。
五、🛡️ 接口幂等和消息幂等有什么区别
先记住一句话:
接口幂等管“请求重复”,消息幂等管“消息重复”。它们发生的位置不同,但目标都是:可以重复来,业务不能重复生效。
5.1 用 aboss-sso 创建账号讲接口幂等
假设 HRMS 要在 aboss-sso 中给张三创建账号,请求里带着:
externalSource = HRMS
externalId = E1001
phoneNo = 13800000000因为网络卡顿,HRMS 连续调用了三次创建账号接口。
如果完全不防重,可能出现:
第 1 次请求 → 创建账号 A001
第 2 次请求 → 创建账号 A002
第 3 次请求 → 创建账号 A003同一个张三就有了三个账号。
aboss-sso 当前不会这样。它在创建前会查询:
- 这个手机号是否已经存在。
- 这个 externalSource + externalId 是否已经存在。
所以按顺序调用时,实际过程是:
第 1 次请求
→ 没查到张三
→ 创建账号 A001
→ 调用 IDaaS
→ 发送账号变更消息
→ 返回 A001
第 2、3 次请求
→ 查到张三已经存在
→ 抛出重复错误
→ 不再创建账号,也不再调用后面的 IDaaS 和 MQ这就是你多次请求却没有看到重复账号的原因。
那它到底算不算幂等
可以这样理解:
它在串行重复请求下有幂等效果,因为最后只有一个账号;但它不是完整的幂等方案。
原因是第一次返回 A001,第二次却返回重复错误,并没有把第一次的 A001 再返回给调用方。
另外,代码使用的是“先查询、再插入”。如果两个请求同时到达,它们可能同时查到账号不存在:
请求 1:查询,不存在
请求 2:查询,也不存在
请求 1:创建 A001
请求 2:创建 A002accountId 不重复只能说明两个主键不同,不能说明业务上没有重复账号。
更稳妥的做法是:
- 给 externalSource + externalId 建联合唯一索引。
- 重复请求到达时,查询并返回已经创建的 accountId。
- 如果需要区分“同一次重试”和“一次新的创建”,再增加 requestId。
理想效果是:
第 1 次请求 → 创建成功,返回 A001
第 2 次请求 → 不再创建,仍然返回 A001
第 3 次请求 → 不再创建,仍然返回 A0015.2 再用账号变更消息讲消息幂等
账号 A001 创建成功后,aboss-sso 会发送一条账号变更消息。
由于生产者重新发送或 MQ 重新投递,ACL 可能收到三次相同消息:
第 1 次:accId=A001
第 2 次:accId=A001
第 3 次:accId=A001ACL 消费者会先按 accId 查询:
第 1 次收到 A001 → 数据不存在 → 新增
第 2 次收到 A001 → 数据已存在 → 更新
第 3 次收到 A001 → 数据已存在 → 更新最后数据库里仍然只有一条 A001。
这说明消费者产生了幂等效果,但不代表消息只发送了一次。消息可能真的处理了三次,只是重复处理没有新增三条账号。
如果消费逻辑是“给用户发一张优惠券”,就不能只按账号覆盖更新,否则消息来三次可能发三张券。这时要用发券流水号等业务唯一键记录“这件事已经做过”。
5.3 两者的区别
| 对比项 | 接口幂等 | 消息幂等 |
|---|---|---|
| 谁重复了 | HTTP/RPC 请求 | MQ 消息 |
| 常见原因 | 重复点击、网络重试、RPC 重试 | Broker 重投、消费超时、生产者重发 |
| aboss-sso 例子 | HRMS 重复调用创建账号 | ACL 重复收到 A001 的账号消息 |
| 常用幂等键 | requestId 或 externalSource + externalId | accId、事件 ID、业务流水号 |
| 最终目标 | 不重复创建账号 | 不重复产生消费副作用 |
5.4 面试时怎么回答
接口幂等处理的是同一个接口被重复调用,消息幂等处理的是同一条业务消息被重复消费。以我们的 aboss-sso 为例,创建外部账号前会根据手机号以及 externalSource 加 externalId 查重,所以串行重复请求不会重复创建账号,但当前没有 requestId,而且是先查询再插入,并发场景还需要业务唯一索引兜底。账号变更消息即使重复到达,ACL 也会先按 accId 查询,存在就更新、不存在才新增,因此最终只有一条账号数据。两者发生的层次不同,必须分别保证幂等。
六、📡 RPC 超时了,服务端到底成功没成功
先说答案:
第一次提示超时后,不能直接告诉用户“创建失败”,因为账号可能已经创建成功。应该提示“结果正在确认,请勿重复提交”。
6.1 第一次点击后,页面应该怎么显示
用户点击“创建账号”后,页面先做两件事:
- 把按钮改成“提交中……”并暂时禁用,防止用户连续点击。
- 为这次创建生成一个固定的请求号,例如 R10001。
如果 3 秒后还没收到结果,不要提示:
创建失败,请重新提交这个提示会诱导用户重新创建,容易产生重复数据。
更合适的提示是:
提交超时,创建结果正在确认中,请勿重复提交。页面可以继续自动查询结果,按钮则显示“查询结果”或“重试”。这里的“重试”仍然使用原来的 R10001,不能生成新的请求号。
6.2 用户又点了一次,会发生什么
假设第一次点击时,服务端已经创建了账号 A001,只是返回结果时超时。
用户第二次点击后,理想流程是:
第 1 次:R10001 → 创建 A001 → 返回结果时超时
第 2 次:仍使用 R10001 → 查到第一次已经成功 → 返回 A001第二次点击不是重新创建,而是在问服务端:
“刚才 R10001 那次创建,现在是什么结果?”
服务端可能返回三种结果:
| 服务端状态 | 页面怎么提示 |
|---|---|
| 第一次已经成功 | “账号创建成功,账号为 A001” |
| 第一次还在处理中 | “账号仍在创建中,请稍后查看” |
| 第一次确实没有成功 | 使用同一个 R10001 继续重试 |
所以页面上的按钮最好叫“查询结果”或“重试本次提交”,不要叫“重新创建”。
6.3 aboss-sso 当前再次点击会怎样
aboss-sso 当前没有 R10001 这样的 requestId,也没有保存第一次的处理结果。
假设第一次已经创建 A001,只是页面收到超时。用户再次使用相同资料点击创建时:
第 1 次 → A001 已经创建成功,但页面超时
第 2 次 → 根据手机号或 externalSource + externalId 查到账号已存在
→ 返回“手机号已使用”或“externalId 不唯一”因此用户看到的可能是:
第一次:提示超时
第二次:提示账号已存在实际上账号可能早就创建成功了,只是第二次请求没有把原来的 A001 返回出来。
当前页面如果遇到这种情况,可以再按 externalSource + externalId 查询账号:
- 查到 A001:提示“账号已创建成功,账号为 A001”。
- 没查到:再提示真正的创建失败。
更理想的后端处理是:重复请求查到已有账号后,直接返回原来的 A001,而不是只抛重复错误。
6.4 如果用户连续快速点两次呢
前端禁用按钮只能减少重复点击,不能作为最终保证。
两个请求如果几乎同时到达,可能都在创建前查到“账号不存在”。因此服务端还需要对 externalSource + externalId 建立联合唯一约束,保证数据库最终只允许一个账号创建成功。
可以把完整过程记成:
第一次点击
→ 按钮变成“提交中”
→ 超时后提示“结果正在确认”
再次点击
→ 使用同一个 requestId 或业务键
→ 已成功就返回原账号
→ 处理中就继续等待
→ 确实失败才重新执行6.5 try-catch 和设置 3 秒超时解决了什么
- try-catch 只能捕获超时异常。
- 3 秒超时只规定调用方最多等待多久。
- 它们都不能判断服务端有没有创建成功。
真正解决问题的是:
- 第一次和第二次使用同一个请求号或业务键。
- 服务端保证幂等并能查询第一次的结果。
- 页面不要把“超时”直接显示成“创建失败”。
6.6 面试时怎么回答
用户第一次点击创建账号后,我会立即禁用提交按钮并显示“提交中”。如果 RPC 超时,不直接提示创建失败,而是提示“结果正在确认,请勿重复提交”。因为服务端可能已经创建成功,只是响应没有及时返回。用户点击重试时,仍然使用第一次的 requestId,或者继续使用 externalSource 加 externalId 这个稳定业务键。服务端如果发现第一次已经成功,就返回原来的 accountId;还在处理就返回处理中;确实没有成功才继续执行。我们当前的 aboss-sso 没有 requestId,第一次成功但回包超时后,第二次请求会因为手机号或 externalId 已存在而报重复错误。更好的做法是查到旧账号后直接返回原 accountId,并用数据库唯一约束解决并发重复创建。
七、🎤 把三个问题连起来回答
这三个问题看起来不同,其实都在说同一件事:
| 面试问题 | 不能想当然的地方 | 正确思路 |
|---|---|---|
| 数据库插入 | 查询时没有,不代表下一秒还没有 | 唯一键、事务、数据库原子约束 |
| MQ 消费 | 消息不一定只来一次,也不一定全局有序 | 顺序键、幂等、状态机、重试补偿 |
| RPC 调用 | 调用方超时,不代表服务端失败 | 请求号、幂等、结果查询、对账补偿 |
你可以用下面这段作为整场面试的总结:
这几个问题的共同点是,分布式环境中不能假设请求只会执行一次,也不能根据调用方看到的结果直接判断服务端状态。数据库层面我会使用唯一约束和事务,MQ 消费会使用业务顺序键、幂等和状态机,RPC 写接口会使用请求号、结果查询和补偿机制,最终保证业务数据正确。
八、📌 面试前一分钟速记
8.1 数据库索引
联合索引像通讯录,先按第一个字段排,再按第二个字段排,所以要注意最左匹配。索引能加快查询,但也会增加写入成本,最终通过
EXPLAIN验证。
8.2 RocketMQ 顺序
单个队列可以有序,多个队列之间不保证全局顺序。同一订单的消息要使用相同顺序键进入同一个队列,并顺序消费。
8.3 消息幂等
MQ 可能重复投递。使用订单号、流水号等业务唯一键,配合幂等记录、数据库事务和唯一索引,保证重复消息不会重复生效。
8.4 接口幂等
接口幂等的关键是给同一次业务操作一个重试时不变的 requestId。服务端用唯一索引抢处理资格,并保存处理状态和第一次结果;相同 requestId 再来时不重复执行业务,而是返回处理中或第一次结果。
8.5 RPC 超时
超时表示没有及时拿到结果,不等于服务端失败。写接口要带唯一请求号,支持幂等、结果查询、安全重试和补偿对账。