公司用本地消息表做分布式事务的最终一致性。订单库 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 冲突而丢消息。
