两套用户体系,一个人:为什么我没有合并账号表
在两套系统、两个维护者、没有单一权威的前提下,让数据仍然可解释、可对账、可回退。
一个人在两套系统里,常常是两个账号。
我们的小程序和官网各自有一套后端、一套用户表、一套注册登录。用户先用手机号在小程序注册,后来到官网,还要再注册一次。业务上的诉求很朴素:让系统知道,这两个账号背后是同一个人。
最直接的方案是合并账号表。我没有这么做。
一、为什么不合并:短期与长期成本都更高
根本原因不是架构洁癖,而是成本。我当时的判断很直接:不合并用户表,短期改造和长期维护的成本都更低。
短期内,网站和小程序是两套完全不同的技术栈,用户表结构和注册逻辑也不一样。强行合并,不是建一张取字段并集的大表就结束:我要先解构两套体系,再保证任何一端新增或编辑用户时,另一端都能理解这些字段;还要逐字段判断,它只影响本端读取,还是会影响双端。历史上的重复手机号、空值和格式差异,也会在同一次不可逆迁移里集中爆发。
长期看,合并意味着把两个系统的命运绑在一起。共享数据源一旦出问题,网站和小程序可能一起失效;为了消除这个点,又得做数据库冗余。主从、读写分离和多份数据之间的同步,会继续带来延迟与一致性问题。在系统存在分区的情况下,可用性、一致性和分区容忍之间,终究要做取舍。对一个体量不大的团队来说,这些不是免费获得的“统一”,而是会持续付费的技术债务。
数据所有权也不能忽略。网站侧有独立负责人,日常功能由同事维护。合并表不是一次技术动作,而是把两边的发布权、排障权和数据解释权放进同一个抽屉。
所以我选择弱耦合:各自保留用户表,只在“身份”这一层建立对应关系。它不是理论上最干净的方案,却是当时总成本更低、也更容易负责到底的方案。
二、联系方式与跨端身份关联键要分开
设计关联键时,我先把两件事分开。
手机号是一种联系方式。它会换、会重复、会被改绑,所以不能单独承担稳定的人员身份关联,更不能被理解成设备标识。
跨端编号用于回答「这两个账号是否要关联为同一个人」。双端采用不同号段:先建档的一端发号,后到的一端采用对端编号;撞号时先建档者优先。
这里有意不设单一权威。两边各记一份跨端绑定登记表,没有任何一方是另一方的从库;不同号段则用来消除双端各自发号时的碰撞空间。
我在建这套体系之前就有一条底线:数据可以互通,但两个系统必须保持独立运行,谁也不能把对方拖垮。如果网站是主库,网站挂掉,小程序的注册和登录也会被拖住,这正是我最害怕的连续故障。
所以我接受的取舍是:部分资料可以暂时不一致,随后再对账;但任何一端的数据或服务出问题,都不能让另一端的核心链路跟着崩溃。用 CAP 的语言说,我是在双端分区存在时优先保住各自可用性,接受关联资料的最终一致,而不是追求所有数据在所有时刻都强一致。
换绑则用预占锁:本端主动换绑优先,先乐观检查旧手机号;目标号在双端都空闲才放行,被占用就拒绝,没有例外。
三、失败姿势要分链路
关联系统最难的部分不是“通了”,而是“不通的时候怎么办”。我对不同不一致的容忍度并不一样。
- 注册 fail-open。注册是高频入口,对端不可用时就拒绝本地注册,等于把整个业务入口一起关掉。不同号段让本端仍能独立发号,暂时缺失的关联交给后续对账补上。
- 换绑 fail-closed。换绑相对低频,却直接碰登录凭证。如果一端换了、另一端没换,用户可能拿着同一个手机号进入两个不同账号。此时宁可暂停办理,也不能制造错绑。
- 后台查询置灰。不展示可能过期的关联信息,避免运营根据旧数据做判断。
这三种姿势看起来不一致,但它们对应的是三种不同的错误代价:注册失败的代价是阻断业务,换绑失败的代价是破坏身份,后台展示错误的代价是误导人的决策。
对于那些无法靠规则自动解决的资料冲突,我还要求后台必须有高亮的降级台账。系统可以降级运行,但不能安静地把问题藏起来;维护者必须能看见哪些记录正在等待人工处理。邮件或消息提醒可以进一步增强及时性,但不能替代这条可见的处置入口。
四、一致性靠两张网
资料同步走事务性 Outbox:本地事务同时写业务表和待发箱,异步推送对端;对端做窄更新,空值不覆盖已有值;报文里带编号校验,编号和手机号对不上就不应用,并写入一条降级记录。
但实时推送只能保时效,保不了最终一致。所以每天凌晨还有一层对账 Job:双端各拉对方快照,互相修复。
联调时撞出两个设计时没想到的问题。
一是“对端有、本端无”的记录如果都挂账,会变成常态噪音。双端本来就是两个独立用户池,单边陌生号是正常状态。规则后来改成:只挂本端登记表里有这个号的记录。
二是双端时间戳相同、内容哈希不同时,“后修改者胜”没有裁决力,两边互修永远不收敛。规则后来改成:平局不修,进入人工处理。
这两条都不是漂亮的设计,而是被真实数据逼出来的。最终一致不是一句口号,它必须把“不收敛的情况”显式收进人的流程。
图 1:两套用户体系各自保留用户表,通过 Outbox 与每日对账两张网交换数据;降级台账共同可见,但不是共享主库。
五、安全不是共用一个密钥
双端是双向调用,所以做的是双向验签:双方各自持有私钥、交换公钥;我方私钥签调对端,对端私钥签调我方,双方请求的业务响应都验证通过才算通。不是共用一个共享密钥,而是一方向一对密钥、按模块扩授权。写操作把请求体 SHA-256 和幂等键签进报文,再受 AppId 与模块授权矩阵约束。
这里踩过一个很典型的坑:网关会把下游异常包装成 HTTP 200 加业务错误信封。任何经网关的服务间调用,只看 2xx 会被骗。所以成功判定必须嗅探业务信封,而不是状态码。这个细节不在任何设计文档里,是联调时用真实请求打出来的。
六、允许单条失败,绝不允许失败无声
存量数据回填用了三步:本端只读导出,带校验和;对端生成预览清单,分类处置;对端执行并回写本端,最后双端行数复核。
分类里,一部分按对端号段顺发新号,一部分直接采用小程序既有编号。最终双端登记数一次对上。
批量执行时,我不希望某一条失败就让整批 SQL 停止,甚至把前面已经执行的部分一起回滚。这里必须区分两件事:允许单条失败、继续处理其他记录,是为了保住批次;让失败静默消失,则会让我连问题存在过都不知道。所以每一条失败都必须能定位、能入账、能进入后续处置,不能只剩一句“脚本跑完了”。
为什么也不用看起来更省事的 INSERT IGNORE?因为前一次回填的教训是:它会把“已存在”的行静默跳过,执行显示成功,行数却少了。你看到的是一句成功日志,没看到的是缺失的数据。
所以这次设了行数硬闸:缺行绝不放行,预演报告和正式执行报告逐字节对拍。行数不是全部正确性的证明,还要结合前置预览、校验和与异常台账;但没有这道硬闸,脚本返回成功就毫无意义。后来这条变成该类迁移的固定动作,而不是一次性的补丁。
图 2:不同链路对应不同错误代价;批量执行允许单条失败继续,但失败必须被定位、入账并保持可见。
七、上线那天
2026 年 8 月 30 日深夜,两端在同一个窗口部署。01:18 摘流,02:05 新码在产,02:45 静态资源收齐,03:35 对账首验完成。对外停机约二十分钟,维护页承接,没有白屏,全程零回滚。
冒烟不是只看“能不能通”:裸调必须 400,错签必须 401,双向验签才 200;按手机号能查到对端记录;改一次昵称,走完 Outbox、推送、对端窄更新、ACK 的完整链路,再还原跑第二轮;后台互跳也要 200。
当晚也踩了两个只有真实环境才有的坑:网站网关缺一条路由,因为生产 nginx 会先剥掉前缀,本地联调没有这一层,暴露不出来;另一处是 nginx 配置里的变量被 shell 吃掉。都当场热修。
凌晨三点半的对账首夜,匹配上的记录只有几条,修复 0 条。这个 0 比任何“全绿”都让我安心:它说明回填时的判断质量站得住。
但不是全绿。补票动作触发了 429,记录到几十条。原因是对账 Job 没有节流,补票请求直接把对端限流打满。
八、429 之后改的是制度
第二天的对账次夜就自愈收敛了。但我们没有把它当成偶发,因为真正严重的问题不是几条报错,而是对账机制差点被自己的请求打瘫。
讨论时有一版方案只修出站、不动入站。被否掉的理由是逻辑不对称:两边的补票都会打到对方,只治一边,等于把压力转移给对方。也讨论过改成批量接口,最终没有采用:批量会引入更复杂的部分成功语义,而首夜大量补票主要是存量数据处理,不值得为它改写交互契约。最后定的是双向对称硬化:出站节流、入站限流调整、再加并发保护。
2026 年 9 月 2 日部署,9 月 3 日首夜:双端零 429,对照此前同一窗口的十余条;修复计数双端为 0,失败计数全表为 0;两条存量的漏网号码沿着新链路自动补上了。
如果重新设计第一版,我会把调用频率当作正式的设计输入:单位时间允许多少补票、429 后如何退避、双端各自能承受多少压力,都应该在上线路前就写清楚,而不是等对方限流来教我。
这单的主体改动在小程序后端,网站侧只做最小配套,由网站侧同事合并与维护。我没有把两边的活都算成自己的。很多实现由多智能体在任务契约与独立复审流程中完成,我负责需求、关键取舍与最终验证结论。
九、还有一次热修事故
部署次日早上,一次热修又暴露了另一个问题:单模块打包时漏了依赖聚合参数,旧依赖进了包,系统服务崩溃循环约四十分钟。
它跟用户关联功能本身无关,但留下的纪律很具体:单模块出包必须带依赖聚合参数,全反应器重包后才允许复验。这跟 429 一样说明,上线不是“功能对了”就结束。
十、人裁的地方
首夜还出现过两件资料冲突:一边的头像和另一边不一样,一边的昵称和另一边不一样。业务负责人拍板“双端一致、取后改方”,网站侧落地,时间戳对齐胜方。
它本身只影响极少数记录,不像 429 那样几乎阻断对账,不需要夸大成一次大事故。但它仍然说明了一条边界:自动对账可以发现差异,却不一定有权决定哪份资料才是正确的。
由此形成一条制度建议:运营账号的双端资料默认人裁,不进自动收敛。技术能自动化的地方,不代表应该自动化。
十一、它到底有多大
2026 年 9 月 24 日做过一次只读巡检:双端登记表各有百余行,互校一致;真正完成关联的用户只有十余人;对账 Job 每天凌晨准点互证,修复 0;待发箱、锁和处理记录零积压;修复之后没有再出现 429。
真实规模很小。这不是一个高并发系统,我也没必要把它包装成那样。
它解决的是一个更朴素的问题:在两套系统、两个维护者、没有单一权威的前提下,数据仍然可解释、可对账、可回退。
未解决的边界也清楚:手机号仍是弱身份,换号、重复号、历史脏数据都还在;哈希平局仍然要人工,自动收敛有天花板;它只解决了“人”的关联,没有解决权限统一和单点登录;对账是最终一致,不是强一致。我离开那家公司之后也没有继续跟踪,不能宣称它长期零故障。
十二、我学到的
工程判断不是选最先进的方案,而是选一个总成本可承受、失败时能说清楚发生了什么、并且能退回去的方案。
合并账号表看起来更干净,但它会把两个系统的命运绑在一起;弱耦合关联更啰嗦,却让每一次失败都停在可解释的边界内。真正需要设计的不是“所有数据永远一致”这句愿望,而是三张清单:哪些不一致可以接受,哪些错误绝不能发生,哪些问题必须显式交给人。
这套东西真正值钱的地方,不是它让两个系统“看起来像一个”,而是它承认它们本来就是两个,然后为这种分裂设计了一套诚实的相处方式。