IM 消息 ID 的核心要求是:分布式 + 有序 + 高性能,只有雪花算法同时满足这三个条件。Leaf-segment 依赖数据库,在 IM 的峰值场景下会成为瓶颈。ULID 的随机尾部不适合做分页查询。Redis INCR 的网络往返开销在高并发下不可接受。
业界大厂的选择也验证了这一点:Twitter 发明了雪花算法(可详读《什么是Snowflake算法?》),Discord、Instagram 的分布式 ID 都基于雪花算法的思想。微信虽然没有公开具体方案(详见《微信的海量IM聊天消息序列号生成实践(算法原理篇)》),但从其技术分享可以看出,消息 ID 也是时间戳 + 序列号的思路。
// SnowflakeIdService.java
private final long twepoch = 1288834974657L; // 起始时间戳(和 Twitter 一样)
private final long workerIdBits = 5L; // 机器 ID 占 5 位(0-31)
private final long datacenterIdBits = 5L; // 数据中心 ID 占 5 位(0-31)
private final long sequenceBits = 12L; // 序列号占 12 位(0-4095)
单毫秒内最多生成 4096 个 ID,对 IM 系统来说绰绰有余(绝大多数 IM 系统单节点 QPS 达不到 400 万)。
6.3、时钟回拨:雪花算法的阿喀琉斯之踵
雪花算法有一个众所周知的脆弱点——依赖系统时钟单调递增。如果时钟回拨(当前时间比上次生成 ID 的时间还早),就可能生成重复 ID。
我的实现选择了最保守的策略——直接报错:
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id");
}
宁可服务不可用,也不能生成重复 ID。这是正确的 trade-off——消息 ID 重复的后果(消息混乱、覆盖、丢失)远比短暂不可用严重。
幂等性(Idempotency):同一条消息处理多次和一次的结果相同。服务端收到消息后,先用 clientMsgId 查 Redis:"这个 ID 我处理过没有?"处理过就直接跳过。这样即使消息因为重试被消费了 3 次,也只会入库 1 次。
有序性(Ordering):消息按 msgId 严格递增。客户端拉取离线消息时,用 lastMsgId 做游标:SELECT * FROM msg WHERE chatId = ? AND msgId > lastMsgId ORDER BY msgId ASC。这个查询保证消息的展示顺序和发送顺序一致。
这两个维度是正交的——去重不需要知道顺序,排序不需要知道是否重复。分开处理,逻辑清晰,互不干扰。如果只用一个 ID 试图同时解决两个问题,去重逻辑和排序逻辑会纠缠在一起,改一个另一个出 bug。
public static long extractUserRegistrationTime(String compactUserId) {
long id = Long.parseLong(compactUserId);
long timestamp = id >> USER_TIMESTAMP_SHIFT;
return (timestamp + USER_ID_START_TIME) * 1000; // 秒 → 毫秒
}
排查用户问题的时候,看到用户 ID 就能算出注册时间,不需要查数据库。这是雪花算法的一个隐藏福利。
十、ID方案的设计原则
回顾三个 ID 的分工,可以提炼出几条 ID 设计的通用原则。
☞ 原则一:一个 ID 只干一件事。clientMsgId 管去重,不管排序。msgId 管排序,不管去重。chatId 管会话隔离,不管其他。不要让一个 ID 承担多个职责。一旦 ID 职责交叉,去重逻辑和排序逻辑就会互相干扰,改一个另一个跟着出 bug。