阅读主题
这个 ID 在识别什么?
先从一台设备上的一条待办开始。我们创建“买牛奶”,把标题改成“买两盒牛奶”,再把它标记为已完成。页面上先后出现了三个状态,用户却始终认为自己在处理同一条待办;提醒、备注和修改记录也应继续归在它下面。
这件事先不需要任何 ID 生成算法。我们只要回答一个更早的问题:系统需要持续辨认的,究竟是哪个对象? 本节沿着同一条待办展开,再依次区分实体身份、数据库主键、公开编号和请求标识。
2.1 先确定:ID 要让谁保持可辨认?
2.1.1 改了内容,为什么还是同一条待办?
需要在变化中持续辨认的对象,通常称为实体。一条待办、一个账户、一张订单都可以是实体。实体的身份回答“是不是同一个对象”;标题、状态、金额等属性回答“这个对象现在是什么样”。
ID 是 identifier 的缩写,即标识符。这里先用 task-A 指称刚才那条待办,暂时不讨论它怎样生成:
text
task-A:买牛奶 → 未完成
↓ 修改标题
task-A:买两盒牛奶 → 未完成
↓ 标记完成
task-A:买两盒牛奶 → 已完成三行描述的是同一条待办在不同时刻的状态。若每次改标题都生成新的实体 ID,原来指向 task-A 的备注和提醒就必须跟着改;否则,它们会留在旧对象上。对这个应用来说,改标题只是修改属性,并没有“创建另一条待办”的业务含义,所以实体标识应当保持不变。
反过来,今天和明天可以各有一条“买牛奶”。两条待办的标题甚至其他属性都可能相同,用户却可以只完成其中一条。内容相同不足以判定是同一个实体,内容不同也不足以判定是不同实体。 标题适合搜索和显示,在本例中却不能单独承担身份标识。
“复制”“同步”和“恢复”可以检验这条约定是否清楚:
- 点击“复制待办”会产生一条可以独立编辑的新待办,因此应获得新身份。
- 把原待办同步到电脑只是增加一份副本,仍应保留原实体的身份。
- 删除后重新创建同名待办是新对象,旧备注不会自动转给它;恢复已删除待办时,恢复的才是原对象。
这几种操作都可能在数据库里新增一行,业务含义却不同。数据库不能只凭两条记录的内容替我们作出判断,身份规则必须来自产品对操作的约定。
2.1.2 主键找到的是哪一行?
把待办存入关系数据库,通常会为表声明主键。主键是一列或一组列,用于在该表中唯一标识一行。以 PostgreSQL 为例,主键约束要求这些值唯一且非空,也允许使用多列组成主键;它并不要求必须有一个名叫 id 的自增整数字段。具体语义见 PostgreSQL 主键约束文档。
如果应用只使用一个数据库,并且一条待办始终对应一行,那么直接用主键值作为待办标识,可以是充分而简单的设计。比如,tasks.id = 17 既能查到那一行,也能指称对应的待办。区分“主键”和“实体身份”并不意味着一定要增加两个字段;在前提成立时,一个值可以同时承担两种职责。
同步到多台设备后,前提就变了。手机表中的 id = 1 和电脑表中的 id = 1 都满足各自表内的唯一约束,却可能指向两条不同的待办。数据库并不会因为两张表都声明了主键,就替它们建立跨设备的共同编号规则。
反过来,同一条待办同步到另一台设备时,也可能在目标表中取得不同的本地主键。可以把本地行号和共享的待办标识分别保存:
text
同一条待办 task-A
/ \
手机本地记录:local_id = 1 电脑本地记录:local_id = 27
task_id = task-A task_id = task-A两个 local_id 只负责在各自的数据库中定位行,task_id 才负责说明两行记录的是同一条待办。这里把字段拆开,是为了容纳已有的本地编号;如果共享标识本身能满足本地表的要求,也可以直接用它作为主键。怎样生成共享标识,后面再讨论。
主键约束提供的是表内行的唯一性;让这个值同时承担实体身份,还需要应用定义它的使用范围和变化规则。 主键也不是记录的物理地址。数据库搬移或重排一行,不应被误认为业务对象换了身份;这里说的“定位”,只是按键查找记录。
2.1.3 给人看的编号,和内部主键是什么关系?
团队待办工具里,同事可能会说:“帮我看看 TODO-104。”这个编号方便搜索、沟通,也可能出现在分享链接里。它承担的是公开标识的角色:应用允许使用者通过它引用一条待办。
公开标识和内部主键可以使用同一个值,也可以分开:
- 内部使用不便口述的长标识,对外提供简短编号;
- 让同一个值同时满足数据库存储和对外引用的要求。
分开之后,需要维护公开编号到实体的对应关系;共用一个值,则要接受它同时受到两套要求约束。多一个字段带来使用上的便利,也带来一份映射的维护责任。
一旦编号出现在聊天消息或书签里,引用就离开了数据库的直接控制范围。重建内部记录时可以调整内部编号,但已经发出去的链接仍要找到原对象;如果更换公开编号,还要决定旧编号如何继续解析。稳定引用的具体做法将在后文展开。
这里容易混淆的是分类角度:实体标识说明要识别谁,主键说明在表中承担什么约束,公开标识说明交给谁使用。 它们不是互斥的几种 ID。一个值可以同时承担多个角色,也可以由多个值通过映射共同指向一个实体。
2.1.4 待办的编号,为什么不能代替请求编号?
给待办 task-A 改标题是一次操作,把它标记为完成又是另一次操作。日志如果只记录 task-A,可以查到关于这条待办的所有记录,却不能仅凭这个值区分是哪一次调用。
因此,还可以给每次调用分配请求标识。本例约定每次客户端发出的调用使用一个新的 request_id:
text
request_id = req-81,task_id = task-A,修改标题
request_id = req-82,task_id = task-A,标记完成两个请求操作同一实体,所以 task_id 相同;它们是两次调用,所以 request_id 不同。命名和粒度是本例的约定,实际系统需要在接口与日志规则中明确,不能只从字段名推断。
一次用户操作还可能触发多个内部调用。此时可以另约定一个关联标识,把相关记录归为一组。它识别的是一段协作过程,而不是那条待办;把同组记录连起来有助于排查发生了什么,却不自动保证业务动作只执行一次。请求重试时怎样辨认重复意图,以及幂等键承担什么职责,留到本章后面的请求案例中讨论。
对实体、调用和协作过程分别编号,不是为了让每行日志更长。它们各有不同的“一次”和不同的结束时间。 待办可以存在几个月,一次调用可能很快结束;把请求编号当成待办编号,会把对象的连续存在与操作的反复发生混在一起。
2.1.5 让业务动作检验身份
回到待办应用,先不选择生成算法,也可以写出明确的预期。下面的检查依据的是产品约定,而不是某个 ID 库的行为。
表 2-1:不同操作对待办身份的影响。
| 操作 | 实体身份的预期 | 可以观察的结果 |
|---|---|---|
| 改标题、标记完成 | 保持原身份 | 原备注仍属于这条待办 |
| 新建同名待办 | 产生新身份 | 完成其中一条不改变另一条 |
| 点击“复制待办” | 产生新身份 | 两条待办可以独立编辑 |
| 同步到另一台设备 | 保持原身份 | 两份记录可被认作同一条待办的副本 |
| 删除后重新创建同名待办 | 产生新身份 | 旧引用不会转而指向新建对象 |
| 恢复已删除的原待办 | 恢复原身份 | 原有引用仍指向被恢复的对象 |
这些检查不要求同步后的数据立即一致,也没有解决并发编辑。它们只确定同步和编辑应围绕哪个对象进行。如果连对象都认错了,后面的状态合并再精确,也是在合并两件不同的事。
现在可以把这一节的结论压缩成一句话:实体身份决定“是不是同一个对象”,主键决定“怎样在一张表里找到一行”,公开标识决定“别人怎样引用它”,请求标识决定“是哪一次调用”。接下来还要回答一个直接的问题:两个设备都给各自的新待办分配了 1,系统应当怎样让它们不再冲突?这就需要说明“唯一”究竟要在哪个范围内成立。