迁移手册:导入 → 双跑 → 切换
从其它 IM 迁入本平台,正确路线是三阶段:先导入用户/群/关系(不动流量),再双跑(旧新并行、以新为准逐项验证),最后切换(停旧写、清尾差、切 Token 签发、保留回滚窗口)。本页给出每一步的真实接口、幂等键、校验点与回滚方案。
本平台没有“导入指定时间戳历史消息”的接口;message.private.publish / message.group.publish 只能以当前时间入库。
如果必须保留旧历史,请把旧消息归档到你的业务系统(只读查询),IM 内从切换时刻开始新历史;不要用循环补发伪造时间戳,会造成未读、推送与审计混乱。
阶段 0:评估与映射(1–3 天)
| 项 | 要做的事 | 产出 |
|---|---|---|
| 身份映射 | 旧 userId → 新 userId(建议新 ID 统一前缀,避免与历史冲突) | 映射表(唯一索引、可回滚) |
| 会话映射 | 单聊/群/聊天室/系统会话按 ConversationType 归类;群 ID 重新分配或沿用 | 会话映射表 |
| 消息类型映射 | 旧自定义消息 → 本项目 content.type(text/image/file/voice/location/custom/recall/system/combine) | 类型映射表 + 不支持的降级方案 |
| 能力差异 | 对照功能矩阵确认长连接、推送、审核、翻译等能力边界 | 差异清单与产品决策 |
| 容量 | 按峰值连接/消息/群成员做容量估算 | 容量与配额申请单 |
阶段 1:导入(不停旧系统)
导入只写新平台,不切流量。所有写接口都要可重复执行:重复导入不能产生重复用户/群/关系。
| 顺序 | 数据 | 接口 | 幂等 / 限制 |
|---|---|---|---|
| 1 | 用户 | user.register | userId 幂等(已存在则更新 name/avatarUrl) |
| 2 | 用户校验 | user.batch.get / user.get | 批量 ≤100;缺失在 missing 返回 |
| 3 | Token(可选) | user.token.issue | 有效期 7 天;封禁/注销用户 1016 |
| 4 | 群/超级群 | group.import | 单次 ≤50,逐条幂等;返回 imported/failed |
| 5 | 群成员 | group.members.add | 批量拉人;普通群上限 3000 |
| 6 | 好友关系 | friend.add | 双向直接建立,幂等;不经过申请流程 |
| 7 | 标签/订阅(可选) | user.tag.batch.set / user.subscription.set | 标签 ≤20×32;订阅 ≤100 |
| 8 | 抽样验证 | 逐项对比用户/群/好友数量与关键字段 | 差异必须为 0 才进入双跑 |
import { AppClient } from '@ni-paas-im/server-sdk';
const app = new AppClient({ appKey: process.env.IM_APP_KEY, appSecret: process.env.IM_APP_SECRET, baseUrl: 'http://127.0.0.1:8080' });
// 用户:分批导入,单批 ≤100(与 batch.get 上限一致,便于逐批校验)
for (const batch of chunk(legacyUsers, 100)) {
for (const user of batch) {
await app.registerUser({ userId: user.newId, name: user.nickname, avatarUrl: user.avatar });
}
}
const check = await app.batchGetUsers({ userIds: legacyUsers.slice(0, 100).map((u) => u.newId) });
if (check.missing.length) throw new Error(`missing users: ${check.missing.join(',')}`);
// 群:单次 ≤50,逐条幂等;失败明细保留重试
for (const batch of chunk(legacyGroups, 50)) {
const result = await app.openApi('group.import', {
groups: batch.map((g) => ({
groupId: g.newGroupId,
name: g.name,
owner: g.ownerNewId,
members: g.memberIds,
groupType: g.memberCount > 3000 ? 'super' : 'normal',
})),
});
if (result.failed?.length) console.warn('group import failed', result.failed);
}
// 好友:直接建立双向关系(幂等)
for (const rel of legacyFriends) {
await app.openApi('friend.add', { userId: rel.a, friendId: rel.b });
}不要给导入用户触发真实推送/短信;导入只是建数据。
群人数超过 3000 必须建为超级群(groupType:"super"),否则成员添加会 1015。
导入完成先做只读抽样(用户/群/好友/会话),不要提前让客户端登录新平台。
阶段 2:双跑(旧新并行)
双跑期旧系统仍是主链路,新平台接收影子流量并验证一致性。目标是让“切换”变成一次低风险操作,而不是第一次真跑。
| 动作 | 做法 | 验证 |
|---|---|---|
| 影子写入 | 业务服务在写旧 IM 的同时,把同一业务事件写新平台(消息用固定 clientMsgId) | 两边消息数、会话数、未读对账 |
| 消息镜像 | 旧消息事件 → 业务服务转成新平台 message.*.publish(仅用于验证/归档,不伪造历史时间) | 抽样对比内容、顺序、会话归属 |
| 回调镜像 | 新平台回调写入影子库,与旧回调做事件级对账(按 eventId/业务键) | 回调成功率、重复率、延迟 |
| 客户端灰度 | 内部员工/小流量客户端连接新平台(独立 Token),不切换全量 | 连接成功率、重连率、关键页面耗时 |
| 差异修复 | 对账差异按“缺用户/缺群/缺关系/消息不一致”分类修复后重跑 | 差异收敛到 0 |
双跑至少连续 3–7 天无差异,且覆盖高峰时段,才进入切换。
阶段 3:切换与回滚
- 切换前冻结
停止旧系统新写入(或进入只读);记录切换时间点 T0 与旧系统最后游标。
- 清尾差
把 T0 前未同步的用户/群/关系做最后一轮导入;消息不迁移,业务侧记录“新历史从 T0 开始”。
- 签发新 Token
客户端登录改为向业务服务申请新平台 Token(user.token.issue);旧 Token 停止下发。
- 验证
登录→连接→会话→发消息→离线补发→多端→回调→审核,按上线检查清单逐项打勾。
- 回滚预案
保留旧系统只读与旧 Token 校验 7–14 天;若出现阻断级问题,客户端切回旧配置,新平台停止写入并保留数据。
- 旧系统下线
观察期满、业务确认无回滚需求后,再回收旧系统资源与数据(按合规保留期)。
| 时间点 | 事项 |
|---|---|
| T-14 天 | 完成映射与容量评估;启动导入 |
| T-7 天 | 导入完成 + 抽样校验;开始双跑 |
| T-3 天 | 双跑差异归零;灰度客户端验证 |
| T0 | 停旧写、清尾差、切 Token |
| T0 + 1 天 | 全量观察;处理首日问题 |
| T0 + 7~14 天 | 保留回滚窗口 |
| T0 + 14 天后 | 旧系统下线与数据归档 |
不可迁移 / 需要业务决策的项
| 项 | 现状 | 建议 |
|---|---|---|
| 历史消息 | 无导入接口,只有当前时间入库 | 旧历史归档只读;新历史从 T0 开始 |
| 好友申请历史 | 只有当前关系;friend.add 直接建立 | 申请记录留业务库 |
| 用户密码/凭证 | IM 只管 Token,不管密码 | 账号体系仍由业务/IdP 负责 |
| 群公告/群文件 | 无对应导入接口 | 公告转成首条系统消息或业务侧展示 |
| 聊天室历史 | 聊天室本身无历史 | 无需迁移 |
| 推送 token | 设备需重新注册到新平台 | 切换后客户端启动时重新注册 |