几百个圈子的“合并同类项”:一次社区数据迁移的完整方案

封面:圈子合并迁移

社区里 500 多个圈子,一半是同一个主题被拆出来的。用户被稀释,运营管不过来,于是要在不丢流量、不丢用户关系的前提下合并成 60 余个,还要随时能整体回滚。这篇复盘完整方案。

本篇你能学到 ​

  • 判断合并容器该新建还是复用旧圈
  • 用一张映射表同时兜住读写两条路径
  • 定清哪些数据迁、哪些交给任务重算
  • 按固定顺序执行迁移脚本并做数据核对
  • 把回滚脚本当成变更包的一部分

一、圈子为什么会膨胀 ​

1. 满员分裂留下的历史包袱 ​

社区里的圈子,是用户加入兴趣主题、看内容、发帖、围观圈内成员实盘动态的地方。早期设计里单圈有人数上限,满员后系统自动分裂出一个同类新圈承接溢出用户。

加上运营跟随热点手工建圈,几年下来圈子总量积累到 500 多个,其中大量是同一个主题被拆成好几个圈的情况。

这套机制早期保护了单圈活跃度,副作用却越来越明显。同一主题的圈友散落在多个圈子,互动密度上不去;搜索和推荐里,用户面对四五个名字几乎一样的圈子,不知道进哪个;几百个圈子也让运营失焦,重复动作此起彼伏。

2. 四条业务目标 ​

需求一句话概括:同类圈子合并、取消人数上限、停掉自动分裂。拆开是四条:

  1. 取消圈子人数限制;
  2. 停用满员自动分裂机制,同时取消“同类圈子只能加一个”的限制;
  3. 后台圈子列表增加权重展示列,并按权重排序;
  4. 全部流量入口平滑切到新圈子,旧链路不失效。

分裂逻辑本身不复杂,核心就是一个判断:participant_limit 到了就新建圈子。

写成伪代码是 if (memberCount >= participant_limit) createNewCircle()。这次要做的,是把这个判断从定时任务里摘掉,同时保证已经分裂出来的圈子被收回去。

预期结果:四条同时达成才算合并完成。只做了数据迁移而入口没切,用户看到的还是旧圈。

二、合并容器:新建,还是旧圈吞并 ​

1. 旧圈吞并的三个问题 ​

第一个要拍板的问题是谁当容器。方案 A 选一个存量圈子,把其他圈子的成员并进来。诱惑很直接:不用新建 ID,不用加映射表。

推演下去有三个问题。属性归谁:合并后的名称、头像、简介以哪个圈为准,拿哪个圈当容器哪个圈的属性就获胜,多组之间变成运营博弈。

脏数据混入:往存量圈灌成员,原生成员和迁移成员从此分不开,出问题只能按用户逐个修,无法整块回滚。聚合口径割裂:容器圈的榜单、热股既有存量数据又有新灌数据,前后口径不一致。

2. 新建圈子加映射表 ​

最终采纳方案 B:每组建一个全新圈子,旧圈通过映射表跳转。代价是一张映射表和各入口的重定向改造,换来三个收益。

  • 可整体回滚:新圈子是本次全新插入的,回滚时按新圈 ID 整块摘除;
  • 可追溯:迁移动作全部有日志,每个成员怎么进来的查得清;
  • 属性干净:新圈属性复制自基准圈,也就是同组中人数最多、ID 最小的旧圈,保证新圈长得像最主流的那个,避免运营博弈。

旧圈采用软下线:不再出现在搜索与推荐里,但保留可访问性,访问时重定向到新圈。历史上被分享出去的链接全部自然承接。

伪代码层面,一个合并组就是一次 createCircle + migrateMembers + insertMapping + softOffline(oldIds)。顺序不能反,映射要在入口切换前就位。

预期结果:每组产出一个新圈、N 条映射、旧圈标记为软下线,任何一步失败都能按新圈 ID 整块回滚。

三、整体设计:映射表是唯一翻译层 ​

1. 映射表结构与索引 ​

数据层新增一张映射表,记录新旧圈 ID 和分组:

sql
CREATE TABLE t_circle_merge_mapping (
    id            bigint GENERATED BY DEFAULT AS IDENTITY,
    new_circle_id numeric(20) NOT NULL,   -- 合并后的新圈子ID
    old_circle_id numeric(20) NOT NULL,   -- 合并前的旧圈子ID
    kind_id       numeric(20),            -- 同类圈子分组ID
    create_time   timestamp DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT t_circle_merge_mapping_pkey PRIMARY KEY (id)
);

CREATE INDEX idx_mapping_old_circle_id ON t_circle_merge_mapping (old_circle_id);
CREATE INDEX idx_mapping_new_circle_id ON t_circle_merge_mapping (new_circle_id);

old_circle_id 上的索引是必须的。详情页每次访问旧圈都要查这张表,它是整个承接链路里最高频的查询。

预期结果:按旧圈 ID 查映射走索引,详情页重定向的耗时与直查圈子详情相当。

2. 读写入口向映射表收敛 ​

具体改哪些功能,各家产品差别很大,照抄清单没意义。抽象来看,一次合并要动的代码归成四条主线。

写路径:所有用户加入圈子的入口统一先过映射表,请求带旧圈 ID 就翻译成新圈 ID 再落库,改造锚点是加圈服务 CircleServiceImpl#join。这样即使某个入口漏改,用户加进去的依然是新圈,行为兜底正确。

读路径:按 ID 读圈子详情,命中映射表就重定向到新圈;所有列出圈子的场景,包括列表、搜索、推荐,统一过滤掉已被合并的旧圈。

异步任务:停用满员自动分裂的定时任务;榜单、热股等聚合统计改为对合并后的数据重算。管理后台:提供新旧映射的维护与查询页面,圈子列表增加运营权重列。

核心思路是让映射表成为新旧世界之间唯一的翻译层。入口漏改一个不可怕,可怕的是漏改的入口行为不确定;统一过表之后,漏改的入口顶多体验差一点,比如多跳一次,不会写错数据。

重定向的交互定为先跳转、后提示:先把用户带到新圈子,再视情况告知发生过合并,避免用户停在旧圈看到一个死页面。

预期结果:旧圈 ID 的加圈请求落到新圈,旧圈详情页重定向到新圈,列表和搜索不再出现旧圈。读写两条路径的走法如下。

配图

图 1:映射表翻译层,旧圈 ID 写路径翻译,读路径弹回新圈

图 1:映射表翻译层,旧圈 ID 写路径翻译,读路径弹回新圈

3. 收尾:把人数上限关掉 ​

人数上限不用改表结构,也不动存量字段。直接把 participant_limit 置为极大值、full_status 清零,再配合校验逻辑下线,双保险。

预期结果:加圈接口不再因为满员拒绝,自动分裂任务也不再触发。

四、迁移策略与上线执行 ​

1. 手动与脚本分工 ​

圈子处理分两类,边界要先划清:

场景处理策略执行方式
单个圈子保持原样 / 仅改名 / 直接下线产品在后台手动操作,不进脚本
多个同类圈子前置改名 → 新建基准圈 → 迁移圈友 → 建立映射自动化迁移脚本

全量圈子里有一小部分走手动通道,改名或下线即可,不需要迁数据;其余按同类拆成 60 余组,全部走脚本。

哪些进脚本、哪些手动做,本身就要和产品逐条确认成一张清单。这份清单与脚本同等重要,漏掉一个圈,它就会以旧姿态残留在线上。

还有一层是配置与逻辑分离。60 多组合并规则是纯数据,产品临时增删一组,只改一行配置,不动过程代码。

预期结果:清单上的每一个圈都有归属,脚本组和手动组不重不漏。

2. 哪些数据迁,哪些不迁 ​

口径先定死,脚本才好写:

数据口径理由
圈子主体属性复制基准圈全部属性到新圈名称 / 头像 / 简介一次性定型
圈友关系(含消息订阅关系)全量迁移 + 去重用户在多个旧圈时只保留一条,状态优先级最高
圈友操作日志随迁移同步补记迁移动作可审计、可追溯
帖子-圈子关联全量复制量级可控,且幂等可重放
动态类内容(交易动态 / 晒单)每组只带最新 1000 条全量太重,新圈也不希望被陈年内容淹没
话题关联配置每组复制最新一条配置类数据取最新即可
收益榜单 / 持仓热股不迁,由任务重算聚合数据必须与任务口径一致(详见踩坑)
更早的历史动态不迁留在旧圈,旧圈仍可访问

这张表里最容易被忽略的是聚合数据那一行。聚合数据一旦预刷,就和定时任务形成两个真相,任务下一跑可能覆盖预刷结果,中间还会出现口径不一致窗口。

迁移只迁事实数据,一切计算结果交给任务重算。上线后第一个任务周期跑完,新圈榜单自然齐了。

预期结果:脚本只写事实数据,新圈榜单和热股在任务周期结束后由聚合任务补齐。

3. 上线顺序与数据核对 ​

窗口选非交易日的午后低峰,提前在测试环境完整演练一轮。执行顺序固定为:序列同步、事务内跑迁移脚本、数据核对、打开各入口的新圈展示。

序列同步必须排第一,因为新圈用手工指定的 ID 提前插入,主键序列不会自动跟上,setval 没做就会炸后续无关业务的主键冲突。

核对方式是迁移前按旧圈口径算出每个新圈的预期成员数,迁移后与实际值逐一比对:

sql
-- 预期值:按映射表把各旧圈去重后的成员数聚合到新圈维度
SELECT m.new_circle_id, COUNT(DISTINCT u.member_id) AS expect_cnt
FROM t_circle_merge_mapping m
LEFT JOIN t_circle_member u ON u.circle_id = m.old_circle_id
GROUP BY m.new_circle_id;

除了数字核对,还要抽样走查:访问旧圈详情页应重定向到新圈,搜索旧圈名不应再出现旧圈。上线后盯三个指标:加圈接口错误率、重定向命中率、新圈内容流刷出率。

预期结果:每个新圈实际成员数与 expect_cnt 一致,旧圈入口全部重定向,三个监控指标无异常尖峰。整场上线按下面这张 Runbook 走。

配图

图 2:上线 Runbook,序列同步、迁移脚本、数据核对、入口切换,回滚脚本常备

图 2:上线 Runbook,序列同步、迁移脚本、数据核对、入口切换,回滚脚本常备

常见错误与排错 ​

  1. 现象:迁移完成后,别的业务插入数据报主键冲突。原因:新圈用手工指定的 ID 提前插入,主键序列还停在旧位置。解法:把 setval 同步放到迁移脚本第一步,再跑后续插入。

  2. 现象:脚本跑到一半失败,重跑时报唯一键冲突。原因:插入没做幂等,重放会重复写映射和成员。解法:写入统一加 ON CONFLICT DO NOTHING,让脚本可以无脑重放。

  3. 现象:同一用户在新圈出现两条成员记录。原因:DISTINCT ON 的去重结果由 ORDER BY 决定,排序字段没覆盖状态优先级。解法:明确同一用户保留哪条,把状态优先级写进 ORDER BY。

  4. 现象:上线后新圈榜单和预刷结果对不上,任务一跑又变。原因:预刷的聚合数据和定时任务形成两个真相。解法:放弃预刷,榜单和热股由任务重算,等第一个任务周期跑完。

  5. 现象:旧分享链接打开是 404。原因:旧圈被物理下线。解法:改为软下线加重定向,旧圈保留可访问性。

本篇小结 ​

  • 合并容器要新建,旧圈吞并会让原生成员和迁移成员永久混在一起。
  • 映射表是唯一翻译层,写路径翻译、读路径重定向,漏改入口也不会写错数据。
  • 迁移只迁事实数据,榜单和热股交给任务重算。
  • setval 是脚本第一步,ON CONFLICT DO NOTHING 是重放保险。
  • 回滚脚本要和迁移脚本同评审、同入库、演练时同执行。