文章 555
评论 5
浏览 199996
SpringBoot + 本地消息表与主库写入不一致:分布式 ID 冲突导致插入失败?雪花算法+唯一索引防重

SpringBoot + 本地消息表与主库写入不一致:分布式 ID 冲突导致插入失败?雪花算法+唯一索引防重

公司用本地消息表做分布式事务的最终一致性。订单库 INSERT 了订单,消息表 INSERT 了一条"待发送"记录。偶尔消息表 INSERT 失败报唯一键冲突——两个不同的服务实例生成了同样的消息 ID。订单已经入库了,消息没有入库,订单的状态就永远卡在"待发送"。对账的时候才发现有几十笔订单"消息未下发"。

问题出在 ID 生成方式上。他们用的是数据库自增 ID——但在分布式环境下,两个实例各写各的订单库,各自生成了相同的自增 ID(比如都是从 1 开始),然后在消息表里产生了冲突。

今天聊聊怎么用雪花算法生成全局唯一 ID,配合唯一索引做防重,让消息表的 INSERT 不再因为 ID 冲突而失败。


数据库自增 ID 为什么不行

本地消息表的标准做法是:

1. 订单库 INSERT 订单(ID=1001)
2. 消息表 INSERT 消息(msg_id=1001, 关联订单 1001)
3. 定时任务轮询消息表,发送消息
4. 发送成功 → UPDATE 消息状态 = SENT

但如果你有两个服务实例,各自的订单库都有自增 ID=1001。两条订单对应同一个 msg_id=1001,消息表里就冲突了。

自增 ID 是单库范围的唯一。跨库之后,它不再唯一。


雪花算法:全局唯一、不依赖数据库

雪花算法(Snowflake)生成一个 64 位的 long 型 ID:

| 1 bit | 41 bits 时间戳  | 10 bits 机器ID | 12 bits 序列号 |
| 未用  | 毫秒级          | 1024 台机器    | 每毫秒 4096 个 |

毫秒级递增,天然适合做数据库主键(B+树分裂少)。全局唯一,不需要依赖数据库自增或 Redis 计数器。

Java 里的实现很多,Hutool 的 IdUtil.getSnowflake() 就能直接用:

@Component
public class IdGenerator {
    private final Snowflake snowflake = IdUtil.getSnowflake(1, 1); // workerId, datacenterId

    public long nextId() {
        return snowflake.nextId();
    }
}

k8s 环境下的 workerId 可以通过环境变量注入——用 Pod 的 IP 地址最后一段做 workerId,保证每个 Pod 不重复:

workerId = podIpToInt(env("POD_IP")) % 32

唯一索引做最后防线

雪花算法解决了 ID 生成的问题,但不能解决"同一条业务消息被重复插入"的问题。

假如你用了雪花算法生成 msg_id=7834567890123456789,但代码里如果出 bug,同一条订单的消息被插了两遍——msg_id 是不同的(每次调用 snowflake.nextId() 生成新 ID),但关联的订单是同一个。

这时候需要在消息表上建一个业务唯一索引

-- 消息表
CREATE TABLE message (
    msg_id BIGINT PRIMARY KEY,        -- 雪花 ID
    order_id BIGINT NOT NULL,         -- 关联订单
    status VARCHAR(20) NOT NULL,
    UNIQUE KEY uk_order_id (order_id) -- ★ 同一订单只允许一条消息
);

uk_order_id 保证同一个订单只能插入一条消息。重复插入时直接抛 DuplicateKeyException,捕获后判断——如果已有消息且状态正常,忽略;如果没有消息但插入报冲突,说明有并发问题,重试。


插入时的容错处理

public void insertMessage(long orderId) {
    Message msg = new Message();
    msg.setMsgId(idGenerator.nextId());
    msg.setOrderId(orderId);
    msg.setStatus("PENDING");

    try {
        messageMapper.insert(msg);
    } catch (DuplicateKeyException e) {
        // 唯一键冲突 → 说明消息已经存在
        Message existing = messageMapper.selectByOrderId(orderId);
        if (existing != null && "SENT".equals(existing.getStatus())) {
            log.info("消息已发送,幂等跳过: orderId={}", orderId);
            return;
        }
        // 存在但状态异常 → 修复
        log.warn("消息已存在但状态异常: {}", existing);
        messageMapper.updateStatus(orderId, "PENDING");
    }
}

异常被吃掉了,但逻辑是完整的——唯一索引冲突不是 bug,而是一个防御信号,告诉你"这条消息已经有过了"。


总结

本地消息表跟主库不一致的根因就两个:ID 冲突(自增 ID 跨库重复)和重复插入(同一条订单多条消息)。

解法:

  • 雪花算法 —— 全局唯一 long 型 ID,毫秒递增,B+ 树友好。k8s 下用 Pod IP 做 workerId
  • 唯一索引 —— uk_order_id,业务层面的防重。重复插入抛异常,优雅处理
  • 容错重试 —— 捕获 DuplicateKeyException,检查已有消息状态,幂等返回或修复

三者配合,消息表再也不会因为 ID 冲突而丢消息。



标题:SpringBoot + 本地消息表与主库写入不一致:分布式 ID 冲突导致插入失败?雪花算法+唯一索引防重
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/29/1782637583345.html
公众号:服务端技术精选

服务端开发博客:后端架构、高并发、性能优化与微服务实战教程

取消