06.03 模式设计与演进:让会话历史经得起改名和退群
返回第六卷 · 关系模型前置:06.01 · SQL 前置:06.02
06.03 模式设计与演进:让会话历史经得起改名和退群
DeepTutor 初稿经技术与教学审阅后的静态课程。字段、迁移、旧版行为和输出均为虚构纸上方案;本次没有运行数据库、Go、IM 服务或站点。本章的 PostgreSQL 与 MongoDB 比较是教学模型,不代表 OpenIM 的实际存储设计。
一、u-a 改名后,m-a 应显示哪个名字
06.01 为 users/conversations/members/messages 画了四张表;06.02 用它们查询 c-a 的历史。现在虚构用户 u-a 从“用户甲”改名为“甲同学”,而 m-a 是改名前发送的。读历史时显示当前昵称还是发送时昵称?这不是 SQL 能替产品回答的选择;两种需求各有合理用途,却决定了字段该放哪、是否允许重复保存。
假设有人把 sender_display_name 复制到每一条消息行,原来 u-a 在 c-a 的 m-a、m-c 都写“用户甲”。改名后若只更新其中一行,历史同一发送者可能一条显示旧名、一条显示新名。若两条都更新,就失去“发送时昵称”的真实快照。同一个重复字段,究竟是无意复制当前值,还是有意保存历史值,要看业务语义。
| 用户动作 | 需要哪些数据 | 设计前必须说清 |
|---|---|---|
| 查看一页历史 | 会话、消息、发送者展示信息 | 展示当前昵称还是发送时快照 |
| 改用户昵称 | 用户当前资料 | 已有历史是否随之变化 |
| 退群再加入 | 两段成员资格 | 旧消息的可见和发送权限怎样判断 |
| 撤回或编辑消息 | 消息当前状态与历史需求 | 保留原文、状态或审计到什么边界 |
这是一张需求/访问模式表,不是实测负载报告。当前课程没有运行中的数据库或线上数据;后续若优化性能,才需测实际访问量、大小和更新频率。
二、函数依赖:发现重复数据的手电筒
函数依赖写成 X → Y:在一张关系的约定语义里,只要两行的 X 相同,Y 就必须相同。它不是“Go 函数调用”,也不是数据库自动创建的约束。比如 users 的 user_id → current_display_name:同一个用户当前只有一个当前昵称;messages 的 message_id → conversation_id, sender_id, body:一条消息 ID 标识一条当前消息记录。06.01 的 (conversation_id,seq) 若在 messages 中唯一,也能决定该行其他列。
把当前昵称冗余放进消息表,就出现 message_id → sender_id → current_display_name 的间接依赖。想象 m-a 和 m-c 都由 u-a 发送,users 已把昵称改为“甲同学”,而消息里的两份复制值只改一份:同一个 u-a 对应两个“当前昵称”,违背了这个字段原本的语义。
| 异常 | 纸上情景 | 为什么发生 |
|---|---|---|
| 更新异常 | A 改名,要修改每条历史消息的当前昵称副本;漏一条就不一致 | 一个当前事实被保存了多次 |
| 插入异常 | 想先创建用户当前昵称,却要求先有一条消息才能在“合并大表”里写下昵称 | 不同对象被强塞进同一行 |
| 删除异常 | 删除某用户最后一条临时消息,若资料只在“合并大表”,昵称也被意外删掉 | 一个对象的生存依赖无关对象 |
这不是要求“数据库永远不能重复字段”。若业务合同明确规定发送时昵称快照,同一 u-a 的不同消息保留不同名称恰好是正确历史;此字段应明确命名 sender_display_name_at_send,在发送时写入后不得随着用户改名而批量改写。当前昵称与历史快照是不同事实,不能共用一个含糊的 sender_name。
三、规范化直觉:把各对象的事实放回归属处
规范化帮助我们按依赖关系拆表,以减少无意重复和更新异常。先从业务事实推导,再把术语放上去:
- 第一范式(1NF)的入门直觉:一行的一列按声明类型保存一个可解释的值。不要把成员名单写成一段以逗号分隔的
"u-a,u-b,u-c",然后靠字符串切割做权限判断。SQL 和文档数据库仍可有数组/JSON;这里强调的是成员关系需要独立身份与查询规则,不是声称“所有数组都不合法”。 - 第二范式(2NF)的入门直觉:若行用复合键识别,普通属性应依赖整个键,而不是只依赖其中一部分。假设一个错误的
message_rows(conversation_id,seq,conversation_title,body)用(conversation_id,seq)作键,conversation_title只由conversation_id决定,标题就会在这个会话的每条消息中重复。把当前标题放回conversations更清楚。 - 第三范式(3NF)的入门直觉:避免把依赖另一普通属性的当前事实混进来。
messages.message_id → sender_id → users.current_display_name正是当前昵称应回到users的线索。正式范式判断还涉及候选键和完整依赖;这里用小例子建立判断方法,不以口号代替严格设计。
拆分后要能按身份正确连接回所需信息,不能把表拆得无法复原业务事实。例如 messages.sender_id 引用 users.user_id,06.02 可按需要连接当前昵称;当前昵称只维护一份。若业务确实要求发送时快照,另给消息行一个语义清楚的历史字段,不把它解释成当前资料的副本。拆分是否无损、关键约束能否表达要在方案评审中检查;只看“表数变多”不能断言模型更好。
四、键、约束和跨行规则各能守住哪一层
06.01 的主键、外键、UNIQUE、NOT NULL 和 CHECK 继续有效:message_id 标识消息,(conversation_id,seq) 限制同会话序号重复,sender_id 外键只要求该用户存在。它们不会自动证明“u-a 在发送 m-a 的那一刻有权限”,也不会自动分配序号或证明已送达设备。
成员重入让这一限制更明显。最初的 members(conversation_id,user_id) 复合键只能保存一行当前关系。若需要完整历史,可在教学模型里另设 membership_periods(membership_id,conversation_id,user_id,joined_at,left_at):u-a 第一次加入、退出、第二次加入各有独立的 membership_id。此时还要定义“同一会话同一用户是否允许两段时间重叠”“当前最多有几段未退出资格”“退群与发送并发时按什么顺序裁决”。一个简单的行内 CHECK(left_at>=joined_at) 不能比较其他行是否重叠;外键也只能确认 ID 存在。具体数据库约束、事务和并发实现后置,不能在模式图上假装问题已经解决。
| 不变量 | 简单列/行约束可表达吗 | 仍需什么 |
|---|---|---|
同一会话 seq 不重复 | 可声明组合唯一 | 并发生成序号和失败重试合同 |
| 发送者 ID 指向已有用户 | 外键可检查存在 | 发送时成员资格和权限 |
| 一段资格退出晚于加入 | 行内 CHECK 可检查 | 多段资格不重叠及并发裁决 |
| 消息被具体设备收到 | 四张表无法推导 | 设备确认事件与可观测边界 |
先写业务不变量,再选择数据库约束与应用代码的分工。若规则跨越多行和时间区间,就不能用一个只看当前行的 CHECK 冒充完整保证。
五、文档模型与有意冗余:谁是权威
MongoDB 的文档能嵌入一起读取的小规模字段,也可用引用连接独立文档。对于虚构 IM,可以把会话少量、边界明确的 settings 放在会话文档;消息历史会无限增长,通常应是按 conversation_id 可检索的独立消息文档,而非把所有消息嵌进一个会话数组。成员与会话又是多对多关系,退群历史更需要明确身份与时间,不能因为集合字段灵活就省掉合同。
一个常见的有意冗余是会话列表里的 last_message_preview:它让列表读取少访问一份消息,但它是从消息派生的展示摘要。若最新消息被撤回、编辑或删除,谁负责更新摘要?若一次更新只成功了一半,读者能否看到旧值,何时修复?设计时须写明权威记录、更新时机、允许的暂时不一致、检测与重建方法。仅凭“嵌入读得快”不能做完整取舍;本章没有性能测量,也不声明某种方案优于另一种。
当前昵称与发送时昵称仍是两种语义:会话列表若想显示现在的用户资料,可引用 users 的权威当前资料;消息历史若想保留当时展示名,可把快照随消息保存,并清楚标注来源。将当前名复制到每条消息再约定“最终同步”是一种有成本的冗余,不能悄悄伪装成不变的历史快照。
六、纸上迁移:新增发送时昵称快照
需求改为“新发消息须保留发送时昵称;旧消息若当时未保存,则不得伪造”。这是一个旧数据不可凭空恢复的演进问题。下面只写静态迁移顺序,不在数据库执行:
| 阶段 | 模式/代码动作 | 兼容与可验证条件 |
|---|---|---|
| 1. 扩展 | messages 新增可空 sender_display_name_at_send | 旧行是 NULL;旧代码忽略新列仍可工作 |
| 2. 新写入 | 新版本发送时写消息 ID、正文及当时昵称快照 | 新行快照来源于当时受信用户资料;旧版写入仍可能留 NULL |
| 3. 新读取 | 有快照时标“发送时昵称”;没有时显示“未知历史昵称”或明确标记当前昵称回退 | 不能把当前昵称回退无标记地冒充过去昵称 |
| 4. 回填与核对 | 只对有可信历史来源的旧行回填,分批记录范围和已处理/未知数量 | 旧数据若无可靠来源,保持 NULL/未知;核对不等于伪造完整覆盖 |
| 5. 收紧与清理 | 在所有写入者升级、旧行策略确定后考虑约束,最后移除旧读取分支 | 若旧行仍需 NULL,就不能直接全表 NOT NULL |
PostgreSQL 教学语法的第一步可表示为:
ALTER TABLE messages
ADD COLUMN sender_display_name_at_send TEXT;新增可空列让旧行在纸上初始为 NULL。不能立即接 SET NOT NULL:老数据没有值、旧服务实例可能仍写不出新字段。即使以后决定用 "历史昵称未知" 作为一个产品认可的占位值,也应把它和真实发送时快照分开标识。回填需要可信历史来源;当前 users.display_name 只能告诉你现在叫什么,不能单独证明发送 m-a 时叫什么。
模式修改在真实系统中可能涉及锁、数据扫描或重写,具体行为受数据库版本、语句、表大小和现有数据影响。本章不做“加列总是瞬时完成”的承诺;在个人隔离环境落实时要对照所用版本文档和实际观测。迁移脚本版本、批次、进度、失败与回退点须记录,10.10 再系统讲发布控制。
七、旧版本并存时,怎样避免半成品数据
迁移期间可能同时有旧服务和新服务。新代码如果只读新字段,旧行的 NULL 会导致历史页面缺失;新字段若立即强制非空,旧代码写入会失败;若先把旧字段删掉,尚未升级的服务会读写错误。这就是“扩展→迁移→收紧→清理”的实用顺序:一段时间内让两版代码都能理解当前模式,先保留旧路径,再逐步观察并收紧。它不意味着每一步都能无损回滚。
| 失败变式 | 立即已知 | 下一份证据或处置 |
|---|---|---|
| 回填到一半中断 | 部分旧行有可信快照,部分仍 NULL | 按稳定键续跑或回滚该批,核对已处理和未知数量 |
加 NOT NULL 失败 | 存在 NULL 或旧写入者 | 查旧行分布、旧版流量与写入合同,不用假值糊过去 |
| 新旧读到不同昵称 | 字段语义或回退策略不一致 | 区分当前名与发送时名,审查版本和记录来源 |
| 删除列后想回退旧服务 | 旧服务依赖的结构已消失 | 预先保留兼容窗口,数据删除不一定可逆 |
last_message_preview 与真实末条不一致 | 派生视图陈旧 | 找权威消息、更新路径与重建任务,标可容忍时长 |
同一业务修改要有验收合同:新消息写入的快照值与当时受信资料一致;旧消息没有可靠来源时明确显示未知;退群再入群的两段资格不互相覆盖;消息历史仍按 conversation_id,seq 稳定读取。这里没有运行数据库,所有状态都是纸上预期。真实前后对比还应记录具体版本、数据量、批次和错误率,不能把“设计了监控字段”写成“迁移成功”。
八、交付一份能被审查的演进方案
学习者交付:一张 user_id/current_display_name、message_id/sender_id/snapshot 的函数依赖表;一张“昵称重复存消息表”导致的更新异常反例;membership_periods 的两段纸上记录及当前关系查询规则;一份五阶段快照迁移计划;再用两句话比较关系表拆分与文档嵌入/引用的取舍。每一项都写出权威来源和无法从旧数据推断的事。
分层练习:先答,再展开反馈
1. `user_id → current_display_name` 表示什么?
在当前资料语义下,同一稳定用户 ID 只有一个当前昵称。
2. 函数依赖就是 Go 函数调用吗?
不是。它描述同一关系中字段值之间的确定关系。
3. 把当前昵称复制到每条消息会带来哪种异常?
改名须更新多行,漏更新就出现同一当前事实的多个版本。
4. “发送时昵称”为什么可以与当前昵称不同?
它们指向不同时间的事实;历史快照不应随当前资料改名而变。
5. `sender_name` 这个模糊列名有什么问题?
读者无法知道它是当前名、副本、发送时快照还是回退值。
6. 为什么不把成员名单存成逗号分隔文本?
成员关系失去独立身份和可约束的键,权限查询容易变成脆弱的字符串处理。
7. 复合键 `(conversation_id,seq)` 下标题只依赖 conversation_id,说明什么?
标题只依赖键的一部分,适合放回 conversations,避免每条消息重复当前标题。
8. `message_id → sender_id → current_display_name` 提醒我们什么?
当前昵称是用户资料的事实,不应无意复制为每条消息的当前值。
9. 拆表越多就必然越好吗?
不是。要检查业务语义、正确连接、关键约束和读取/维护成本。
10. `sender_id` 外键能证明发送时成员资格吗?
不能。它只证明用户存在。
11. 当前 `members` 复合键能保存退出再加入两段吗?
不能,同一用户与会话组合只能有一行;要独立资格身份或事件。
12. 一行 `CHECK(left_at>=joined_at)` 能防两段资格重叠吗?
不能,它只查看该行,无法比较其他资格行的时间区间。
13. `UNIQUE(conversation_id,seq)` 会自动产生并发安全序号吗?
不会。它只拒绝重复,分配与冲突处理要另行设计。
14. 为什么不把全部历史消息嵌入一个会话文档?
历史无界增长,文档大小、分页和独立更新会失去可控边界。
15. `last_message_preview` 应把谁当权威?
它是派生摘要;消息记录及其业务状态才是应明确指定的权威来源。
16. 新增发送时昵称列,第一步为何允许 NULL?
旧行没有快照,旧版写入者也可能暂时不会填;先保证兼容。
17. 当前昵称能直接回填为所有旧消息的发送时昵称吗?
不能,用户可能改过名;缺可信历史时应保留未知并标注。
18. 回填到一半停止,下一步先核对什么?
稳定键范围、已处理量、未知量、失败批次和来源,不盲目从头覆盖。
19. 还有旧服务写入时能直接设新列全表 NOT NULL 吗?
不应。旧行和旧写入可能为 NULL,需先完成兼容与覆盖策略。
20. 直接删除旧列后能保证回退旧代码吗?
不能。旧代码可能依赖被删除的结构,删除数据也未必可逆。
21. 模式图能证明数据崩溃后一定恢复吗?
不能。事务、提交与恢复另需实现和证据。
22. 本章演进方案验收应区分哪些名称?
当前昵称、真实发送时快照、旧数据未知/回退名称必须分开。
来源与下一步
- CMU 数据库课程:Normal Forms 讲义:函数依赖、分解与范式的理论入口。
- PostgreSQL:约束与修改表结构:主外键、CHECK、加列和收紧约束的边界。
- MongoDB:识别访问模式、嵌入、引用与数据一致性:字段归属、冗余和更新取舍。
按学习路线,下一步先读06.10 Go 数据访问,再回到 06.07 的事务语义;索引、页与执行计划在 S4 深化。离开本章前,必须能解释哪份数据是权威、哪些旧事实无法恢复、两版代码并存时怎样兼容。