返回博客

关系表存 A 系统 ID、查时传 B 系统 ID——静默返回空的隐形杀手

某次上线后,某后台管理系统的「会员标签」功能突然对所有人都显示「暂无标签」。去数据库手查,标签数据分明存在;API 返回 200,body 里是空数组;前端没有报错,只有一片空白。整整排查了两轮,才发现问题出在一个谁都没注意过的地方:关系表里存的是微信 openid,但代码查询时传的是 POS 系统的会员编号

现象

  • UI 显示「暂无标签」,但手动 SELECT * FROM user_tags WHERE openid = 'oD4jk5...' 能查到几十条记录
  • SELECT * FROM user_tags WHERE openid = '901097009' → 0 行
  • API 全程 200 OK,没有任何异常日志
  • 自动打标任务跑了多次,写入记录持续增加,但前端依然空白

根因

系统由两套子系统拼合而成:一套是微信生态(用 openid 标识用户),另一套是线下 POS/CRM(用会员编号标识用户)。关系表建表时只考虑了微信侧,openid 列存的是形如 oD4jk5CpI7Is1k26dyT1_VvTXbLU 的字符串。

然而,后台管理 API 接收的是从 POS 侧传来的会员编号(如 901097009),代码直接用它查 openid 列:

// 列名叫 openid,变量却是 memberCode——两个完全不同的值域
->where('openid', $memberCode)

两套标识符互不相交,查询永远返回 0 行。更隐蔽的是,有些地方还加了字段别名掩盖了不对称:

->field('ft.openid as member_code')  // alias 不等于语义等价
坑:列名不等于语义,别名尤其危险

openid as member_code 只是改了输出列名,不代表这两列存的是同一套 ID。跨系统集成时,列名往往沿用了建表时某一侧的语义,随着业务扩展另一侧的调用方开始复用这张表,悄悄埋下不匹配。

最危险的是:查询不会报错,只会返回空——开发者会先去怀疑「同步没跑到」「数据没写进去」,绕很大一圈才想到键域不匹配。

正解

不要每次查询都做实时 JOIN 翻译,而是在关系表里反范式冗余存储第二个系统的 ID,一劳永逸。

第一步:加列加索引

ALTER TABLE user_tags
  ADD COLUMN member_code VARCHAR(50) DEFAULT NULL COMMENT 'POS会员编号(冗余)'
  AFTER openid;
 
ALTER TABLE user_tags ADD INDEX idx_member_code (member_code);

第二步:存量数据回填

通过身份映射表(存储 openid ↔ member_code 对应关系)回填:

UPDATE user_tags ut
INNER JOIN identity_mapping im
  ON ut.openid = im.openid
  AND im.member_code IS NOT NULL
SET ut.member_code = im.member_code
WHERE ut.member_code IS NULL;

SSH 执行时的小坑:条件里尽量用 IS NOT NULL 而不是 != '',空引号经过多层 shell 转义后可能被静默吃掉,导致部分行漏更新。跑完后建议再验一次:

-- 应返回 0,否则说明有漏网之鱼
SELECT COUNT(*) FROM user_tags ut
INNER JOIN identity_mapping im ON ut.openid = im.openid
  AND im.member_code IS NOT NULL
WHERE ut.member_code IS NULL;

第三步:修所有查询点

把用错键的地方全部改过来,写入也不能漏

// 读:改用新列
->where('member_code', $memberCode)
 
// 写:两个键都要落库
$db->table('user_tags')->insert([
    'openid'      => $openid,       // 保留,兼容旧查询
    'member_code' => $memberCode,   // 新增,供管理侧查询
    'tag_id'      => $tagId,
]);

第四步:修上游写入服务

所有异步创建关系记录的服务(自动打标、同步引擎等)也要同时写两列,否则增量数据仍然残缺:

function applyTag(string $openid, string $memberCode, int $tagId): void {
    $db->name('user_tags')->insert([
        'openid'      => $openid,
        'member_code' => $memberCode,  // 必须
        'tag_id'      => $tagId,
    ]);
}
正解:关系表冗余两套 ID,各侧用各侧的键查

两套系统共享关系表时,最稳的做法是在表里同时存两个系统的用户标识。读写都命中本系统的列,不依赖运行时 JOIN,也不怕某一侧的 ID 变更(比如微信重新绑定后 openid 变了,但 member_code 不变,标签依然存活)。

批量处理:从更小的集合侧迭代

如果需要批量给两套系统的用户打标,优先从绑定了两侧 ID 的较小集合迭代,而非全量扫大表。例如:全量 POS 会员 130 万条、已绑定微信的只有 7.5 万条——从 7.5 万侧迭代,每条都有 openid 可用,效率提升约 17 倍,避免 120 万次空查询。

一句话外卖

两套系统共用一张关系表时,先问「这张表里存的是哪个系统的 ID」,再问「调用方传来的是哪个系统的 ID」——不对齐就是定时炸弹,且爆炸时没有任何报错。