同步这件事有个规律:跑通只要一天,跑对要三个月。第一版通常是一个定时任务全量拉一遍覆盖进库,数据量小的时候完全够用;等客户数上万,这个任务开始跑两小时、开始撞频控、开始在跑到一半时挂掉,才发现要改成增量 —— 而增量真正的难点不在怎么拉,在于怎么知道自己漏了。这篇按 wecomapi 的接入方式讲全量与增量怎么分工、水位线怎么定、对账与补偿怎么分层。
先定滞后窗口,别追一致
开工前先回答一个问题:这条数据晚五分钟,谁会因此做出错误的决定?答不上来,就不要为它做实时同步。跨系统同步不存在强一致,你能选的只有「可接受的滞后是多少」,把这个数写下来,后面所有取舍才有依据。
- 消息与会话:秒级。它直接决定客服能不能及时应答,必须事件驱动,不能靠轮询。
- 客户档案、标签、归属:分钟级。业务上很少有场景经不起几分钟滞后,事件驱动为主、定时兜底足够。
- 组织架构、成员:小时级甚至天级。变更频率低、影响面大,稳比快重要,可以接受定时全量。
- 统计口径的汇总数据:不同步。需要时按明细算,同步一份汇总必然和明细对不上。
把这张表写进设计文档,最大的收益不是技术上的,是它挡住了「所有数据都要实时」这个默认需求。企业微信数据同步的成本几乎全压在滞后窗口上:从小时级压到分钟级要加事件链路,从分钟级压到秒级要重做整个消费侧,而后者往往换不来任何业务收益。
全量、增量、回源,三种取数各干各的
很多团队把这三种当成「先进后进」的替代关系,其实它们是分工关系,长期都要留着。
- 1全量快照:只在三个时刻用 —— 建库、对账取基线、数据模型变更后重建。它不该是日常手段,日更全量在数据量涨上来之后一定会先撞频控再撞时间窗。
- 2事件增量:日常主力。变更发生时推过来,滞后最小、成本最低。用 wecomapi 的事件回调拿到变更后,先快速 ACK 再入队异步处理,回调里不做任何同步落库。
- 3按需回源:冷字段不要预先灌进库。一个客户的完整明细可能几十个字段,其中大部分只在打开详情页时才被读一次,同步它们纯属自找对账工作量。
关键判断在这里:事件增量做不了三件事 —— 它覆盖不了漏投的事件、处理不了乱序、也看不见删除。这三件事就是后面几节要解决的。任何「只做增量、不做对账」的方案,都会在三个月内积累出一批说不清来源的脏数据,而且你发现它的方式通常是业务同事的一句「这个客户早就不在了」。
首次全量和实时增量的交接窗口
上线时的顺序错了,会留下一个永远查不出来的洞。直觉顺序是「先拉全量,拉完开事件订阅」,问题在于全量跑了两小时,这两小时里发生的变更没有任何人接住 —— 事件还没订阅,全量已经拉过了。这批丢失的变更不会报错,它们只是安静地不存在。
- 1先开事件订阅,把收到的事件原样写进缓冲区,暂不处理业务。
- 2再跑全量快照,按分片写入,每片带上快照时间。
- 3全量完成后重放缓冲区里的全部事件,包括全量期间和之前收到的。
- 4重放追平后切实时消费,缓冲区按保留期清理。
第三步能安全重放的前提,是写入本身带版本护栏:每条记录存一个版本(用变更时间或平台给的序号),写入时只有新版本能覆盖旧版本,旧版本静默丢弃。有了这条,重放多少遍结果都一样,乱序事件也自动被吃掉 —— 这一条护栏比后面所有对账逻辑加起来都值钱,而它只是 upsert 语句里的一个条件。
版本比较要用同一个时钟源。用你自己入库时的本地时间当版本,两个实例之间时钟差几百毫秒就足以让旧数据盖掉新数据,而且这种问题只在并发高的时候偶发,几乎不可能靠复现定位。
水位线:游标别用「上次同步时间」
增量拉取要记一个水位线,标记「拉到哪了」。最常见的写法是每次跑完把 last_sync_at 设成本次开始时间,下次从这里往后拉。这个写法有三个坑,而且都是静默漏数据,不报错。
- 水位取的是「本次开始时间」而不是「已确认处理完的最大记录时间」。任务跑到一半挂了,水位却已经推进,中间那批记录永久丢失。
- 只用时间戳做分页排序。同一时间戳上有多条记录时,翻页边界会把它们切断,第二页从下一个时间戳开始,被切掉的那几条谁也不管。
- 假设两侧时钟一致。对侧的变更时间和你的本地时间不是同一个钟,差几秒就足以让边界上的记录漏掉。
// 示意逻辑,精确的分页参数与时间字段以 wecomapi 线上接口文档为准
async function pullIncremental(entity) {
const wm = await watermark.get(entity); // 按实体分别存,不要全局一个
let cursor = { since: wm.confirmedAt - OVERLAP_MS, id: null }; // 回退一个安全窗口
let maxConfirmed = wm.confirmedAt;
while (true) {
const page = await source.list(entity, cursor);
if (!page.items.length) break;
for (const item of page.items) {
// 版本护栏:只有更新的版本能覆盖,重叠拉到的重复记录被静默丢弃
await store.upsertIfNewer(entity, item.externalId, item, item.changedAt);
maxConfirmed = Math.max(maxConfirmed, item.changedAt);
}
// 排序键用 (changedAt, id),避免同一时间戳的多条记录被翻页边界切断
cursor = page.next;
if (!cursor) break;
}
// 只有整批确认落库后才推进水位,中途失败下次从旧水位重来
await watermark.set(entity, { confirmedAt: maxConfirmed });
}重叠窗口的大小按两侧时钟偏差和事件延迟估,几分钟通常够,宁大勿小 —— 重叠拉到的重复记录会被版本护栏吃掉,成本只是多几次无效写;窗口不够则是永久漏数据。这是一个典型的用便宜代价换掉昂贵风险的位置,没有必要在这里省。
水位线要按实体类型分别存。客户、群、成员的变更频率和失败特征完全不同,共用一个 last_sync_at 的后果是任何一个实体拉取失败,其它实体也跟着回退重拉,一次小故障放大成一次全线重跑。
删除是增量的盲区
增量拉取只能拿到「存在且变过的东西」,拿不到「已经不存在的东西」。客户被删除、成员离职、群解散之后,如果对应的事件没订阅或者漏投了,你库里那条记录会永远留着 —— 它不报错,只是慢慢变成幽灵数据,然后在某次群发里被当成有效对象用掉。企业微信客户同步里最难查的一类问题就在这。
三种办法要一起用,单靠任何一种都不够。订阅删除类事件是首选,滞后最小,但它和其它事件一样会漏投;周期性的标识集合差集对账是兜底,只拉标识不拉明细,成本比全量低一个量级,适合每天跑一次;存在性回源确认用在差异确认环节,对差集里的可疑记录用 wecomapi 的读接口单独查一次,确认不存在再处理。
处理方式上有一条硬规矩:不要硬删。发现差异时先标记为待确认,观察一个窗口(比如二十四小时)之后仍然确认不存在,再转软删,并保留原始记录。理由很实在 —— 对账窗口错位、分页漏页、临时的可见性变化都会造出假的「已删除」,而误删的恢复成本远高于多留一天脏数据。
软删的记录要在所有下游查询里默认过滤掉,这一条要做在数据访问层,不要指望每个业务查询自己加条件。少加一处,某个报表就会把已删客户算进去,而这种偏差往往几个月后才被发现。
对账分三级,补偿分三类
对账最容易的做法是每天全量拉一遍逐条比对,也是最跑不长的做法:数据量一大就跑不完,跑不完就被关掉,关掉之后你就彻底失明了。正确形状是分三级,每级只在上一级报警时才跑,成本从全量降到差异量。
- 1计数对账:最便宜,按实体和分组比总数。它只能告诉你「有问题」,但一天跑几次都不心疼,是发现异常的第一道岗。
- 2分桶指纹:按标识哈希分若干桶,每桶算一个摘要(标识加版本的聚合值)比对。数据量不变,比对量降到桶数级别,能把问题定位到某几个桶。
- 3明细对账:只对差异桶拉明细逐条比。因为范围已经被前两级压到很小,这一级才跑得起,也才可能每天跑。
还有一个容易被忽略的细节:对账要取一个截止水位,只对账这个水位之前的数据。直接对账「当前全部数据」,边界上正在变更的记录会稳定产生一批假差异,几周之后没人再认真看对账报告 —— 一个天天报错的对账等于没有对账。
补偿按差异类型分三类,只有第一类能自动修。己方缺失(对侧有、你没有)最安全,直接按标识回源补齐即可;字段不一致要先确定权威源,然后单向覆盖,且只覆盖被判定为权威的那几列,不要整行盖 —— 你自己算出来的标签、备注、内部状态不该被同步任务抹掉;己方多出(你有、对侧没有)最危险,它既可能是漏了删除事件,也可能是对账窗口错位,一律走上一节的待确认流程,不自动删。
对账任务不要直接改数据,让它只产出差异单,由独立的补偿任务消费。补偿本身要限速、要幂等、要留痕,并且修复完成后能被下一轮对账验证 —— 一个不会被验证的补偿,和不修没有本质区别。精确的接口能力、分页与频率约束以 wecomapi 线上接口文档为准。
常见问题
- 每天全量拉一遍不行吗?
- 数据量小的时候完全可以,别过早改增量。判断切换时机看三个信号:单次任务时间接近调度间隔、开始撞频率约束、失败后重跑来不及。三个里出现任何一个就该动手。改造顺序建议是先给写入加版本护栏,再接事件增量,最后把全量降级成对账基线 —— 反过来做会有一段时间两条链路互相覆盖。用 wecomapi 接入时这三步彼此独立,可以分三次发版,不必憋成一个大改造。
- 只订阅事件、不做定时对账可以吗?
- 不建议。事件会漏投、会重复、会乱序,这三件在一天之内都不明显,一个季度之后就是一批说不清来源的脏数据,而删除类变更漏掉之后完全没有其它途径能发现。成本可以压得很低:一天一次计数对账加分桶指纹,只有报警时才跑明细,日常开销比一次全量拉取小得多。
- 同一个客户在两条接入路线下标识不一样,怎么同步?
- 在自己库里建独立主键,把各路线的外部标识作为映射列存在旁边,允许一对多。同步任务按外部标识写入、按自己的主键对外提供,两侧都不需要知道对方的标识体系。这张映射表要从第一天维护,攒了几万条数据再补就不是加一列的事了。具体的标识语义与可用字段以 wecomapi 线上接口文档为准。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
