用户在自己的资料编辑页改好昵称、头像、简介,点保存,提示"保存成功"。可一刷新、一重新打开,页面又是空的——所有字段都回到了空白。用户以为"没保存上",其实数据好好地躺在库里,只是存进了另一张表。
现象
表现非常稳定,也很有迷惑性:
- 编辑页填写内容 → 点保存 → 接口返回
200,甚至返回了"保存成功"; - 重新打开该页面 → 所有字段全是空;
- 直接去数据库里查,发现数据确实写进去了,就在某张表里躺着。
也就是说:写入成功,读取为空,数据没丢。 这个组合几乎直接锁定了根因方向——写和读不是同一份数据。
根因
根因是读写指向了不同的表。
这类应用常常把"用户资料"拆成两张表:
- 主表:存用户的核心字段,比如用户名、手机号、状态;
- 扩展表:存可扩展的资料字段,比如昵称、头像、简介、偏好设置。
拆分本身没问题(主表窄、扩展表灵活)。问题出在控制器/服务层的实现上:
- 保存时,代码只写了扩展表;
- 读取回显时,代码只读了主表——而前端要显示的昵称、简介这些字段,恰恰躺在扩展表里。
由于主表里压根没有这些字段,读出来自然是空。于是形成一句绕口令:写一张,读另一张,中间靠人脑脑补。
为什么会写成这样?常见是因为分两次开发:先有主表,页面直接读主表;后来为了让字段可扩展,加了扩展表,保存逻辑改到了扩展表上,但读取逻辑忘了同步改。两边各自能跑通,合起来就错位了。
解决
原则只有一句:写和读必须指向同一份数据。 分表之后,读写要成对出现。
读取时,联表合并返回。 把主表和扩展表的字段合起来查询:
SELECT u.id, u.username, p.nickname, p.avatar, p.bio
FROM user u
LEFT JOIN user_profile p ON p.user_id = u.id
WHERE u.id = :id;
用 ORM 的话,就是让资料对象同时关联两张表——有的框架提供一对一关联,一次查询把两边都带出来;也可以分两次查再在代码里合并成同一个 DTO。
写入时,按字段归属分别落到两张表。 更新用户名这类核心字段写主表,更新昵称、头像这类扩展字段写扩展表,并且放在同一个事务里,避免写了一半、另一半失败造成新的不一致:
BEGIN;
UPDATE user SET username = :username WHERE id = :id;
UPDATE user_profile SET nickname = :nickname, ... WHERE user_id = :id;
COMMIT;
改完之后,务必走一遍完整的读写闭环测试:保存 → 重新读取 → 断言每个字段都对得上。不要只测"保存返回 200"。
延伸与预防
这条坑的通用教训是:"保存成功"只证明了写,证明不了读。 只要数据被拆到了多个位置,就必须用一次完整的读写往返来验证,而不是分别测写入接口和读取接口。
几条可复用的做法:
- 加一条端到端用例。 一条"保存后立刻回读、逐字段比对"的测试,能一次性抓住"写到 A、读到 B"这类错位,比单测更有价值。
- 给字段标注归属。 在设计文档或代码里明确"这个字段属于主表 / 扩展表",让写和读的实现有据可依,减少"改了一边忘另一边"。
- 警惕二次迁移。 从单表拆成多表、或增加了新表时,是最容易产生读写错位的时刻。拆表的同时,把读路径也一并改掉并测过。
一句话:分表之后,"读"和"写"就是一对必须同时检查的操作——它们指向的必须是同一份真相。