加载中...

三十秒版:这一讲在干嘛

41 讲把服务端的【协议层】换成了 RPC 框架,业务层一个字节没改。
42 讲轮到业务层了——它做两件改造:

①  加多租户   —— 每张图从此属于某个用户,别人看不到
②  换真数据库 —— 用 mongodb 替掉"数据只在内存里"

而原文讲这次改造的方式,是按【详细设计】的四段走一遍(正好就是 45 讲的方法论):

① 使用界面   多租户怎么进接口?  加一层?还是加个参数?        → 本篇 二
② 数据结构   选什么存储?表怎么设计?索引加哪两个?            → 本篇 三
③ 算法       每个用户故事怎么走完?(伪代码)                  → 本篇 五
④ 网络协议   uid 从哪来?(这一讲先 mock,43 讲才做真的)       → 本篇 六

★★ 这四步的顺序不是编排,是因果
接口定了才能选存储,表定了才能写算法(为什么不能反,见 1.2)。


★ 而 41 和 42 讲放在一起看,是一对:

41 讲   换掉【协议层】的实现   →  业务层一个字节没改
42 讲   换掉【业务层】的实现   →  协议层只加了一个 uid
        ★ 每次只动一层 —— 这本身就是分层的回报(1.1 有逐文件的行数对照)

这一讲信息量最大,也最容易读成"哦,他们选了 mongodb"。有五处一带而过的地方值得停下来:

  • 多租户不应该影响 DOM 树的结构” —— 只给了结论。推导是什么? 加一层到底会在哪一步崩?(二)
  • db.drawing.findOne({_id: dgid, uid: uid})” —— 这行伪代码是安全的,而原文没说为什么。(2.4)
  • 我们对 Shape 完整的约束是什么样的,欢迎你留言讨论” —— 原文明确抛出的思考题,答案就在代码里,一共四条。(2.5)
  • 凡是涉及到多次修改操作的,都应该以事务形式来做” —— 结语一句带过,其实是整讲最重的一句,而 v42 的代码根本没用事务(用了别的办法,而且做对了)。(五)
  • 原文没提的一件事:换成 mongodb 之后 SetZorder 变成了 return errNotImpl——一个功能被弄丢了。为什么偏偏是它?(四)

★ 另外这一讲有一份文件值得单独拿出来:v42 新增的
README_IMPL.md(153 行)——
它就是上面那四步写成的文档,也是一份「详细设计文档」的模板(1.2 详解)。

怎么读这份带读

这一部分是 建议
第一部分 · 地基(一) 改了什么 · 按什么顺序改的 · v41→v42 逐文件对照 先读这个
第二部分 · 原文二~六 使用界面 / 数据结构 / 换存储弄丢的功能 / 算法 / 网络协议 主体
第三部分 · 引申七~九 让接口不被 uid 污染的第三条路 · 多租户的三种物理落地 · 收口 标了 ⚠ 非原文

只想搞懂某一件事:
多租户为什么不加层 → 二 | 那行伪代码为什么安全 → 2.4 | Shape 的四条约束 → 2.5 |
为什么选 mongodb → 三 | 索引怎么定的 → 3.3 · 3.4 | 哪个功能丢了 → 四 |
没用事务怎么做对的 → 五 | uid 从哪来 → 六 | 怎么让接口不被污染 → 七

这一讲的演示脚本(推荐按这个顺序跑):

# 脚本 演什么 配合
1 代码/多租户三种做法.py 加一层 vs 加属性各推到底 · 三种物理隔离 · ★ IDOR:403 和 404 的差别
2 代码/换存储换掉了什么.py 对象→句柄 · mutex 失效,唯一索引顶上 · 最左前缀 · 链表没了 三 · 四
3 代码/孤立对象与顺序.py 逐个宕机点 · ★ 顺序决定残留什么 · v42 四处代码全对 · 事务的代价
4 代码/Shape的完整约束.py 回答原文思考题:四条约束 · 两种序列化必须同构 · 多态反序列化是封闭点 2.5

这一讲的源码

你要的东西 路径
mongodb 版服务端(v42) 代码/qpaint源码/v42-服务端/
★ 那份详细设计文档 README_IMPL.md
↳ 业务层(这次真的改了,241→270 行) drawing.go
↳ 自定义 Env + mock 授权 service.go(开头 60 行)
新增的 bson 序列化测试 shape_test.go(34→78 行)
v41→v42 的四份 diff diff-drawing · diff-service · diff-shape_test · diff-service_test
上一版(内存版,用来对照) ../41-…/代码/qpaint源码/v41-服务端/

第一部分 · 地基

一 · 这一讲改了什么,以及它按什么顺序改的

1.1 是「改了什么」(逐文件行数对照);1.2 是「按什么顺序改的」(那四段,也是详细设计的模板);
1.3 预告本篇比原文多讲的五处

1.1 41 讲和 42 讲改的是"对称的两半"

41 讲(v31→v41) 42 讲(v41→v42)
service.go(协议层) 286 → 169,重写 169 → 230(加 Env + uid)
drawing.go(业务层) 一个字节没改 241 → 270,几乎全部重写
shape.go(类型定义) 一个字节没改 89 → 89 行,但每一行都改了(见 2.6)
shape_test.go 一个字节没改 34 → 78(新增 bson 测试)
新增文件 go.mod 的依赖 README_IMPL.md(153 行)

★ 两讲正好是一对:41 讲换掉协议层、业务层不动;42 讲换掉业务层的实现、协议层只加一个 uid。
这个"每次只动一层"本身就是分层的回报。

1.2 那四段的顺序,是可以直接抄的

★ 先说清这四段在整个流程的哪一格:它们全部属于「详细设计」这一层
上面还有两层——需求分析(做什么/不做什么)和概要设计(整个系统怎么串起来)。
跳过那两层直接填这四段,做出来的东西很可能又对又没用。

完整流程(横跨 17·18 → 32 → 45 讲)单独出了一份:
带读 20 · 设计一个系统的完整流程
——含每一步的完成标志、最容易跳过的那一小步、以及一张能直接抄的清单

① 逻辑 DOM 结构     这个系统里【有什么】—— 一棵树
② 使用界面(接口)    每个东西【能被怎么用】—— 方法签名 + 约束(含语言之外的)
③ 数据结构           它【存在哪、长什么样】—— 表、字段、索引
④ 实现逻辑(算法)    每个用户故事【怎么走完】—— 伪代码

为什么必须是这个顺序? 因为每一步都在给下一步定题:

定了什么 于是下一步只能…
① → ② 有哪些实体、谁属于谁 接口只能围绕这些实体写
② → ③ 要支持哪些操作 表和索引由"要跑哪些查询"决定(3.3)
③ → ④ 底层能高效做什么 算法只能用存储支持的操作拼出来

★ 反过来做(先选数据库、再想接口)就会出现一类很典型的系统:
接口长得像数据库表的 CRUD,业务语义全在调用方那边。

42 讲的顺序保证了原文那句话成立:
“使用界面(接口)应该自然体现业务需求” ——
因为界面是在还没想过数据库的时候定下来的。

1.3 一句话预告这一讲的五个"更深"

二 · 多租户加一层会在【分享】那一步崩         ——加属性不会
2.4 · 那行伪代码防的是 IDOR                  ——403 和 404 的差别
2.5 · Shape 有四条约束,第 3、4 条原文没提
四 · 换存储把 SetZorder 弄丢了               ——链表在存储里没有对应物
五 · v42 没有用事务,它用了"顺序"            ——而且四处全对

第二部分 · 原文这一讲

二 · 使用界面(接口)

python3 代码/多租户三种做法.py

2.1 先说清三件事,再看原文的问题

原文这一节一上来就是 Document => Drawing => Shape 和"引入多租户要不要变四层"。
四个陌生概念叠在一起,先拆开。

① 「多租户」是什么,以及这一讲为什么突然要加

到 41 讲为止,qpaint 服务端根本没有"用户"这个概念——29 讲那个 mock 服务端上所有的图,谁都能看、谁都能改

单租户   一套部署只服务一个人 / 一个组织
多租户   ★ 一套程序、一份部署,同时服务很多互不相干的用户,
         而且他们的数据【必须互相看不见】

★ 关键词是"互相看不见"——这是一条安全要求,不是一个功能。
功能没做全,用户抱怨;这条没做对,是事故。

那为什么不给每个客户单独部署一套(那样天然隔离)?成本差几个数量级——
所以所有 SaaS 都是多租户的,而"隔离"就成了它必须自己解决的问题
(三种物理落地方式见第八节。)

② 那棵「DOM 树」是什么

从 26 讲就有的 qpaint 模型,服务端沿用了它:

Document          整个服务端上【所有的图】
  └ Drawing       一张图
      └ Shape     图里的一个图形(Line / Rect / Ellipse / Path)
这一层 代码里 URL 里
Document drawing.goDocument (不出现,它是根)
Drawing drawing.goDrawing /drawings/{dgid}
Shape shape.goShape /drawings/{dgid}/shapes/{sid}

"DOM 树"这个叫法来自 36 讲:桌面程序的 Model 层是一棵以 Document 为根的树。
服务端的 Model 层沿用了同一个结构——41 讲第三节说的
“业务逻辑实现层,通常我们有意识地把它组织为一棵 DOM 树”,指的就是这个。

③ 于是 42 讲的第一个问题:用户往这棵树的哪里放

A · 加一层      Document => User => Drawing => Shape
B · 加个属性    Document => Drawing => Shape ,而 Drawing 上挂一个 uid

原文的答案是 B(下面就是原文那段),而 2.2 会把两种各推到底,看它为什么必须是 B。

★ 顺带回答一个你马上会问的问题:uid 从哪来?
来自请求的 Authorization 头。这一讲用的是 mock 的授权(浏览器自己声称 uid=1,见第六节),
43 讲才做真的(OAuth 2.0)。所以本讲可以先把 uid 当成"已经有了"。

原文的问题和答案

之前:  Document => Drawing => Shape                          (三层)
问题:  引入多租户,要不要变成
        Document => User => Drawing => Shape                  (四层)
答案:  不要。正确的是
        <Drawing1>, 隶属于某个<uid>
            <Shape11> …

“多租户只会导致 DOM 树多了一些额外的约定,通常我们应该把它看作某种程度的安全约定
避免访问到没有权限访问到的资源。”

2.2 ★ 推导:拿五件将来一定会发生的事去撞两种设计

先补一环:URL 的路径,就是那棵树的路径

下面那张表的第一行(URL)最容易看不懂,因为它默认你知道这一条:

40 讲第 2.1 节讲过:URL 是一串路径式的下标。

文章库[1]["comments"][5]     ←→     /articles/1/comments/5

qpaint 也一样——DOM 树的每一层,在 REST 里就对应 URL 的一段

Document  =>  Drawing        =>  Shape
                 ↓                  ↓
          /drawings/{dgid}  /shapes/{sid}

★★ 所以「要不要给 DOM 树加一层」这个问题,等价于「要不要给所有 URL 加一段」。

设计 A(加一层 User)的 URL 必然长这样:

Document => User => Drawing => Shape
              ↓        ↓          ↓
      /users/{uid}/drawings/{dgid}/shapes/{sid}

而这一下就撞上 40 讲那条:已发布的 API 是一份不能撤回的承诺
改树的形状 = 改掉全部 URL = 换一套网络协议。

「两个真源」具体是什么问题

服务端已经知道你是谁了——Authorization 头里的 token 里就有 uid。URL 里再写一遍,就有两个地方在说"我是谁":

GET /users/7/drawings/507f1f77
     ↑ 客户端说:我是 7 号
Authorization: Bearer xxx
     ↑ token 说:你是 7 号

于是必然要回答一个问题:它们不一致怎么办?

GET /users/9/drawings/507f1f77      ← URL 说 9 号
Authorization: <uid=7 的 token>     ← token 说 7 号

服务端必须写一段代码检查 url_uid == token_uid,不一致就拒绝。

★ 而这段检查一旦忘了写,就是越权——又回到"靠人记得"这个老问题(第七节)。

更根本的一层:URL 里那个 uid 是客户端说的,token 里那个是服务端签发的。
让客户端有机会声称自己是谁,本身就是个坏设计。

而设计 B 的 URL 里根本没有 uid,归属完全由服务端从 token 推出来,客户端说不上话——
"不一致"这件事从物理上不可能发生。

★ 更根本的判据:uid 对「定位」没有帮助

URL 路径的作用是【定位资源】。 判断某个东西该不该进路径,问一句:没有它,还能唯一定位吗?

/drawings/507f1f77            ← dgid 全局唯一,这一串已经能唯一定位那张图
/users/7/drawings/507f1f77    ← 加上 /users/7/ 对定位【没有任何帮助】

★★ 所以那个 uid 在路径里是【冗余】的——它不参与定位,只是又一次声明"我是谁"。
放一个对定位没用、又可能和 token 冲突的东西进 URL,只有坏处没有好处。

这也顺带解释了下表第二行那个"drawing 的身份":

设计 drawing 的身份 后果
A · 加一层 (uid, dgid) 复合身份 图换个主人 → 身份变了 → URL 变了,而这个 URL 已经发出去了
B · 加属性 dgid 单独成立 换主人只改数据库里一个字段,URL 不动

一条可以直接用的规则

★★ 访问者在 token 里,被访问对象才在 URL 里。

GET /drawings/507f1f77          ← "我的"图。我是谁?看 token
GET /users/9/public-drawings    ← 9 号用户【公开的】图
                                   这里的 9 不是"我",是【被访问对象】,所以它该在 URL 里

同一个 uid 放在 URL 里,含义完全不同:前者是"访问者自称"(不该有),后者是"要访问谁"(应该有)。

五件事各推到底

原文只给了结论。脚本把两种设计各推到底:

将来一定会发生的事 设计 A(加一层) 设计 B(加归属属性)
URL 长什么样 /users/7/drawings/507f/…
⚠ uid 已经在 Authorization 头里了,URL 里再写一遍就有两个真源
/drawings/507f/…
✓ 归属是服务端从 token 推出来的,客户端说不上话
drawing 的身份是什么 (uid, dgid) 复合身份
⚠ 换主人就要换 ID,而 ID 已经发出去了
dgid 单独成立 ✓ 换主人只改一个字段
一张图分享给同事 一个 drawing 要挂在两个 User 下 —— 树崩了(一个节点两个父亲) 加一张 (dgid, uid, 权限) 关系表,drawing 本身不动
列出某用户的所有图 天然支持 给 uid 建索引(原文就是这么做的)
权限模型再升一级(团队) Document => Org => User => Drawing
又加一层,全部接口再改一遍
drawing 上多一个 orgid 字段,DOM 树不动

★ 判断:层级表达「从属」,多租户表达「归属权/可见性」。归属权是一个属性,不是一层结构。

更通用、更该背下来的一条:
把权限编码进【结构】,结构就会随权限模型一起变。
而权限模型是整个系统里最爱变的东西(个人 → 团队 → 组织 → 共享 → 公开链接)。
所以永远把权限做成"可以加字段、可以加关系表"的东西。

最狠的是"分享"那一行:四层树在遇到"一个资源属于多个人"时是结构性地崩掉,
不是"改起来麻烦",是它表达不了。而"分享"这个需求,几乎没有一个协作产品逃得掉。

2.3 接口确实被污染了,原文很诚实

// 之前
func (p *Document) Add() (drawing *Drawing, err error)
func (p *Document) Get(dgid string) (drawing *Drawing, err error)
func (p *Document) Delete(dgid string) (err error)

// 之后
func (p *Document) Add(uid UserID) (drawing *Drawing, err error)
func (p *Document) Get(uid UserID, dgid string) (drawing *Drawing, err error)
func (p *Document) Delete(uid UserID, dgid string) (err error)

“对于 QPaint 程序来说,Document 类之外其他类的接口倒是没有发生变化……
但是这只是因为 QPaint 程序的业务逻辑比较简单。
虽然我们需要极力避免接口因为多租户而产生变化,但是这种影响有时候却是不可避免的。

这个方案的真问题不是"多一个参数",是它靠人记得传。 引申的第三条路见第七节(把「约定」变成「类型约束」)。

2.4 ★ 那行伪代码是安全的,而原文没说为什么

原文的算法和 v42 的真实代码是一致的:

原文:   doc = db.drawing.findOne({_id: dgid, uid: uid})
v42:    drawingColl.Find(M{"_id": id, "uid": uid}).Count()
                            ^^^^^^^^^^^^^^^^^^^^  uid 在【查询条件】里

先把两种写法都写出来

写法 A · 先读后判(很自然的写法,绝大多数人第一反应都是这个):

drawing := db.drawing.findOne({_id: dgid})   // ① 先按 id 读出来
if drawing == nil {
    return 404                                // 没这条记录
}
if drawing.uid != uid {                       // ② 再判断归属
    return 403                                // 有这条记录,但不是你的
}
return drawing

写法 B · 把归属写进查询条件(原文和 v42 用的):

drawing := db.drawing.findOne({_id: dgid, uid: uid})   // 一次查完
if drawing == nil {
    return 404       // ★ "不存在"和"不是你的"走【同一条路径】
}
return drawing

★ 注意:两种写法都没让你读到别人的数据。
所以写法 A 不是"有漏洞"——它泄漏的不是数据,是「这个 ID 存不存在」这个事实。

差别只有一处

脚本实测:

请求(我是 uid=7) 先读后判 条件里带 uid
取我自己的 507f1f77 200 ✓ 200 ✓
别人的 aabbccdd 403 404
不存在的 deadbeef 404 404
先读后判      别人的 → 403,不存在的 → 404      【两个答案不一样】
条件里带 uid   别人的 → 404,不存在的 → 404      【同一个答案】

★ 泄漏「存在性」到底能干什么

攻击者用他自己的合法账号(完全正常登录),拿一批 ID 去扫:

GET /drawings/000001   →  404      不存在
GET /drawings/000002   →  403      ★ 存在!只是不是我的
GET /drawings/000003   →  404
GET /drawings/000004   →  403      ★ 存在

他就枚举出了系统里所有真实存在的 ID。 三种用处:

# 用处
1 知道你的规模——ID 递增的话直接能算出你有多少数据(商业情报)
2 攒弹药——哪天另一个接口忘了做归属校验,他手上已经有一份有效 ID 清单,直接就能拖数据
3 把 drawing 换成别的资源,立刻变得很脏
GET /users/13800138000    →  403   ★ 这个手机号在你这注册过
GET /orders/{订单号}       →  403   ★ 这个订单存在

★★ 拿一批手机号 / 邮箱去扫,403 的那些就是你的用户名单。
"存在性"本身常常就是敏感信息——尤其对婚恋、医疗、招聘、借贷这类产品,
"某人是你的用户"这件事本身就是隐私。

★★ 而写法 B 是「结构上」泄漏不了

关键不是"B 记得返回 404",是它根本没机会知道:

写法 A:  服务端【先拿到了那个事实】(drawing != nil),然后【选择】不告诉用户
写法 B:  数据库返回零行。服务端【压根不知道】"有一行 _id 匹配但 uid 不匹配"

★★ "选择不说"和"不知道"差别巨大
选择就意味着可能选错——日志里打出来了、错误信息里带上了、
或者哪天有人把 403 改成了更友好的提示。

而写法 B 里,"没权限"和"不存在"在代码层面就是同一条路径——你想泄漏也泄漏不了。

这又是第七节那个模式

写法 A   把"要记得不说"交给人的纪律
写法 B   把它变成"根本不知道"        ← 结构性的

★ 403 和 404 不一样,就等于回答了一个攻击者没资格问的问题:「这个 ID 存在吗?」
于是他可以拿一批 ID 去扫,403 的那些就是真实存在的资源

这在 OWASP 里叫 IDOR(不安全的直接对象引用),常年排在 Top 10 第一位;
这条具体的泄漏叫存在性泄漏

★ 所以那行伪代码的正确性不只是"少一次查询",是安全的:
把归属条件写进查询,而不是查出来再判。
数据库天然不会告诉你"有一行匹配 _id 但不匹配 uid",于是
"没权限"和"不存在"在代码层面就是同一条路径 —— 你想泄漏也泄漏不了。

一条可以直接拿去用的准则:租户条件应该出现在 filter 里,不该出现在 if 里。

生态 长什么样
Django Drawing.objects.filter(owner=request.user).get(pk=id)
SQLAlchemy q.filter_by(uid=uid, id=dgid).one()
Postgres 行级安全 RLS(数据库层面强制给每个查询加 filter)
v42 Find(M{"_id": id, "uid": uid})

⚠ 例外:有些产品故意返回 403,因为要显示"这是别人的文档,申请访问?"。
那是一个产品决策,它明确接受了存在性泄漏。默认应该是 404。

2.5 ★ 回答原文的思考题:Shape 完整的约束是四条

python3 代码/Shape的完整约束.py

原文抛了问题就走了:

“但是,是不是这一接口(GetID)就是图形(Shape)的全部约束?答案显然不是……
至于在’实战二’的代码实现下,我们对 Shape 完整的约束是什么样的,欢迎你留言讨论。”

先说清:这里的「约束」是什么

shape.go 里对 Shape 的定义只有三行:

type Shape interface {
    GetID() ShapeID
}

但一个类型要真的能当 Shape 用,光实现 GetID() 是不够的。 原文自己点了这一下:

“但是,是不是这一接口(GetID)就是图形(Shape)的全部约束?答案显然不是……”

★★ 所以这里的「约束」= 你必须做到、但【编译器检查不了】的事。

编译器只认 interface 里那一条。另外三条你违反了,代码照样编过、照样跑,
只是行为错了
——JSON 长得不对、存进数据库读不回来。

2.5~2.7 是一条链,先知道它长什么样,读起来就不费劲了:

2.5   一共有【四条】约束 —— 而只有第 1 条编译器管得了
        ↓
2.6   其中第 3 条(要能存进 mongodb)【逼着把 shape.go 的可见性全改了】
        ↓
2.7   既然编译器管不了这四条,那就用【测试】去管

四条约束

答案在 README_IMPL.mdshape_test.go 里,一共四条:

# 约束 出处
1 语言层:必须实现 GetID() ShapeID shape.go 的 interface
2 json.Marshal 结果必须符合 API 层预期 原文提了,README_IMPL 写了
3 bson.Marshal 结果必须符合 mongodb 预期 42 讲新增的,原文正文没提
4 必须能被反序列化回来——而这一步是封闭的 原文完全没提,见脚本 Shape的完整约束.py

README_IMPL 的原话:

  • 考虑到 Drawing 类的 List 和 Get 返回的 Shape 实例,会被直接作为 RESTful API 的结果返回。所以 Shape json.Marshal 结果必须符合 API 层的预期。
  • 考虑到 Drawing 类的 Add、Set、Sync 传入的 Shape 实例,会被直接写入 mongodb,所以 Shape bson.Marshal 结果必须符合 mongodb 的预期。

★ 注意这两条的形状是一样的:
Shape 实例被"直接"交给外部系统,于是那个外部系统的规矩变成了 Shape 的约束。

"直接"是关键词。 如果中间加一层转换(DTO / 映射层),约束就不会传染进来。
v42 选择了不加那层——省了代码,代价是 Model 类型被两个外部系统同时绑住

这也回答了一个常见困惑:“为什么有人非要写 DTO,明明可以直接返回 Model?”
答案就是这一条:不加 DTO,你的 Model 就同时是网络协议和数据库 schema。

2.6 约束三把 shape.go 的可见性全改了

v41 → v42shape.go 一共 89 行,行数没变,但几乎每一行都动了。 实际 diff:

- type shapeBase struct {
-     ID ShapeID `json:"id"`
+ type ShapeBase struct {                                  ← ① 类型名改成大写
+     ID ShapeID `json:"id" bson:"-"`                       ← ② 加 bson 标签
  }

- type lineData struct {
-     Pt1 Point `json:"pt1"`
+ type LineData struct {                                   ← ① 同上
+     Pt1 Point `json:"pt1" bson:"pt1"`                     ← ② 每个字段都加
  }

  type Line struct {
-     shapeBase `json:",inline"`
-     lineData  `json:"line"`
+     ShapeBase `json:",inline" bson:",inline"`
+     LineData  `json:"line" bson:"line"`
  }

三处改动,各有各的原因:

① 类型名从小写改成大写

shapeBaseShapeBaselineDataLineDatarectDataRectData……

因为 Go 的序列化库靠反射工作,而反射只能可靠地看见【导出】(大写开头)的东西(Go 底子 1.2)。

★ 这是换存储时一类非常隐蔽的 bug:序列化库跳过它看不见的字段,而【跳过是不报错的】。

Python 里完全一样的处境(脚本实测)——很多序列化工具跳过下划线开头的属性:

对象里有:             ['id', 'x', 'y', 'style', '_style']
to_store 存进去的是:   ['id', 'x', 'y', 'style']
★ _style 被【静默丢弃】了 —— 没有报错,数据就是没了

② 每个字段都多加了一份 bson:"…" 标签

因为现在有两个外部系统要读这个结构(Go 底子 2.10 讲过标签是给库看的):

Pt1 Point `json:"pt1" bson:"pt1"`
//         ╰────┬───╯ ╰────┬───╯
//         API 层读这个   mongodb 读这个

★ 反引号里可以并列多个库的标签,互不干扰。这就是"一个类型同时服务两个外部系统"的实现方式。

③ ★ 有一个字段的 bson 标签是 -

ID ShapeID `json:"id" bson:"-"`
//                    ╰───┬──╯  ★ 不写进 mongodb

为什么?看 README_IMPL.md 里 shape 表的设计:

字段名 含义 索引
dgid DrawingID ——
spid ShapeID (dgid, spid) 联合唯一索引
shape Shape 本身 ——

★★ shape 的 ID 被单独提出来放在 spid 这一列了(因为它要参与索引,见 3.3),
所以不需要在 shape 这个内嵌文档里再存一遍——存两遍就有两个真源,还可能不一致。

于是 Shape 这个对象过一趟 mongodb 回来,ID 是空的——由上层(Drawing)负责把 spid 填回去。
这一点在 2.7 那个测试的字面量里能直接看见。

★ 这次改动的代价

26~30 讲讲过"下划线 / 小写 = 这是我的内部细节,我随便改"(带读-01 · 2.6)。
现在存储中间件把这个自由收回了一部分:凡是要落盘的东西,都不能再是"内部细节"。

★★ 推广一下:你的类型一旦要跨出进程(存盘、上网、进消息队列),
它的字段名就变成了对外契约的一部分。改一个字段名 = 一次数据迁移。

这就是 29 讲第 3.4 节那条"接口的严谨程度取决于收回来有多贵",落在字段名这一级上。

2.7 ★ v42 怎么把"约束"变成可执行的东西

shape_test.go 从 34 行涨到 78 行,新增的三个测试用了一个很漂亮的手法:

func bsonMarshal(val interface{}) (b2 []byte, err error) {
    b, _ := bson.Marshal(val)                            // ① 存进去
    val2 := reflect.New(reflect.TypeOf(val)).Interface()
    bson.Unmarshal(b, val2)                              // ② 读回来
    return json.Marshal(val2)                            // ③ 再按 API 的样子输出
}

func TestPathEncode(t *testing.T)     { json.Marshal(val) 必须等于 `{"id":"","path":{…}}` }  // 约束二
func TestPathBsonEncode(t *testing.T) { bsonMarshal(val)  必须等于 `{"id":"","path":{…}}` }  // 约束三
                                                                    ↑ ★ 两个测试要求【一模一样的字符串】

★ 顺带看懂那个字面量里的 "id":""

测试断言的完整字符串是:

`{"id":"","path":{"points":[…],"style":{"lineWidth":0,"lineColor":"","fillColor":""}}}`
   ╰──┬──╯
   ★ id 是空的

两个原因叠在一起,都值得知道:

原因
测试构造的是零值 Shape,没设 ID 所以 json 那条断言里它本来就是 ""
而 bson 那条断言里它必然"" 因为 ID 标了 bson:"-"(2.6 ③)——存进去就没了,读回来当然是空的

★ 所以这个 "" 不是随手写的,它反映了一个真实的设计决定
shape 的 id 存在 spid 列里,不在 shape 文档体里。

⚠ 顺带一个诚实的观察:因为测试用的是零值 ID,它恰好绕开了"bson 会丢掉 ID"这个差异。
这跟设计是一致的(ID 本来就不该走 bson 这条路),但如果哪天有人误删了 bson:"-"
这组测试是抓不到的——它只钉住了"两种序列化必须同构",没钉住"哪些字段该走哪条路"。

★ 它断言的是:过一趟数据库再回来,API 看到的东西必须和没过数据库时完全相同。
一句话同时钉住了约束二和约束三,还顺带钉住了"两者不能漂移"。

这个手法值得抄:当一个类型要同时满足 N 个外部系统的期望时,
不要写 N 条注释,写一个"绕一圈回来必须相等"的测试。

它比逐字段断言强在
端到端 中间任何一环(标签、大小写、类型转换)出错都会被抓住
有字面量 那个写死的 JSON 字符串本身就是接口文档
不可绕过 加一种新图形时你被迫也加一对测试

⚠ 代价:写死字符串的测试很脆(库升级、字段顺序变化都可能让它红)。
所以它适合放在类型定义这一层(字段少、变化慢)。v42 只对 4 个 Shape 类型这么做,是恰当的。


三 · 数据结构

python3 代码/换存储换掉了什么.py

3.1 "存储即数据结构"的完整含义

“对于服务端程序,数据结构不完全是我们自己能够做主的
在 36 讲我们说过,存储即数据结构。所以,服务端程序在数据结构这一点上,
最为重要的一件事是选择合适的存储中间件。然后我们再在该存储中间件之上组织我们的数据。”

桌面端:  数据结构 = 我想用什么就用什么(map、链表、树、指针)
服务端:  数据结构 = 先选一个存储中间件,然后【在它允许的范围内】组织数据

★ 这句话有两半,原文只讲了正面那半:

正面 选了存储,就选定了能高效做哪些操作
反面(第四节要证明的) 选了存储,你就放弃了一批数据结构

3.2 为什么选 mongodb —— 以及这个理由今天要打几折

原文的理由只有一条:

“最重要的理由,是因为图形(Shape)对象的开放性。因为图形的种类很多,
它的 Schema 不是我们今天所能够提前预期的。故此,文档型数据库更为合适。”

⚠ 这个推理在 2026 年要打折,理由有三条:

① 关系型也能 schemaless Postgres 的 JSONB 列(2014 年就有)能存任意 JSON,还能对里面的字段建索引shape 表完全可以是 (dgid, spid, data JSONB)
② 真正需要的能力更弱 看 v42 的实际查询:shape 表只有两种访问——按 (dgid, spid) 取一条、按 dgid 取一批。它就是一张 KV 表,值是个黑盒。这种表用什么都不吃亏
③ 真实的选型理由往往不在技术上 团队熟、不想写 migration、上手快

★ 更准的判断,比原文那条更好用:
如果一张表里唯一需要被查询的东西是主键,而其余内容是个黑盒,那它就是一张 KV 表。
文档型数据库在这种表上更顺手(不用写 migration),关系型也完全够(JSONB)。
真正决定选型的是"你要不要对黑盒里的字段做查询、排序、聚合"——要,关系型开始占优;不要,随便。

顺带接回 37 讲那条:“KV 是一切的基础”shape 表就是一张 (dgid,spid) => document 的 KV 表。

3.3 表结构与索引:为什么是这两个

drawing 表
  _id      DrawingID     唯一索引(主键)
  uid      UserID        ★ 索引
  shapes   []ShapeID     —          ← 就是那个"目录"

shape 表
  dgid     DrawingID     ┐
  spid     ShapeID       ┘ ★ (dgid, spid) 联合唯一索引
  shape    json          —

drawing.uid 的索引 —— 原文:“虽然目前我们没有提供 List 某个用户所有 drawing 的方法,但这是迟早的事情。”

shape.(dgid, spid) 的联合唯一索引 —— 原文:“因为 spid 作为 ShapeID,是 drawing 内部唯一的,而不是全局唯一的。”

这两条都对,但各漏了一半。 3.4、3.5 补上。

3.4 ★ 联合索引的【顺序】为什么不能反

原文解释了"为什么要联合",没解释"为什么是这个顺序"。
索引在物理上就是一张按 key 排好序的表,查询能不能用上它,取决于条件是不是这个排序的最左前缀。脚本实测(1000 条记录,20 张图 × 50 个图形):

查询 索引 (dgid, spid) 索引 (spid, dgid)
列出 dg07 的所有 shape(List 50 行 ✓ 1000 行 ✗(全表)
dg07sp013Get 扫 1 行 ✓ 扫 1 行 ✓
找所有叫 sp013 的(跨图查) 扫 1000 行 ✗ 扫 20 行 ✓

(dgid, spid) 这个顺序,一个索引同时支撑了 ListGet;反过来只支撑 Get

规则叫最左前缀:索引 (A, B, C) 能服务 A / A,B / A,B,C不能服务 B / C / B,C
所以联合索引的字段顺序由"你要跑哪些查询"决定——把"总是出现"的字段放前面。
dgid 每个 API 都带(URL 里就有),所以它是第一位。

这正是 1.2 节那条"② → ③":表和索引由使用界面决定,不是反过来。

⚠ 一个真实细节:drawing 表只给 uid 建了单字段索引,而查询是 Find({"_id": id, "uid": uid})——
这里用的其实是 _id 的主键索引(命中一行再判 uid),uid 那个索引是给将来的 List 准备的。
★ 一个索引服务哪个查询,要具体对一遍,不能凭感觉。

3.5 ★ 唯一索引不只是"加速",它是把约束交给了数据库

v31 是这样防重复的:

func (p *Drawing) Add(shape Shape) (err error) {
    p.mutex.Lock()
    defer p.mutex.Unlock()
    if _, ok := p.shapes[id]; ok { return syscall.EEXIST }   // ← 先查
    p.list.insertBack(dgshape); p.shapes[id] = dgshape        // ← 再写
}

"先查再写"中间有一条缝(39 讲那条缝)。v31 用 mutex 把缝焊住了——在一个进程里。
一旦有两台机器,mutex 各锁自己的,等于没锁。脚本实测:

应用层「先查再写」,两个进程并发    插入成功:['进程1','进程2']   ✗ 重复数据进库了
交给数据库的唯一索引(v42)        插入成功:['进程1']  被拒:['进程2']   ✓

v42 的写法:建索引,然后 Add直接 Insert,不做检查,让数据库报错再翻译:

shape.EnsureIndex(mgo.Index{Key: []string{"dgid","spid"}, Unique: true})
...
func mgoError(err error) error {
    if mgo.IsDup(err) { return syscall.EEXIST }   // 把数据库的"重复"翻成业务的"已存在"
}

★ 判断一:进程内的互斥(mutex / Lock / GIL)在多台机器上一律失效。
换存储的时候,所有靠 mutex 保住的不变量都要重新找一个"分布式的靠山":

原来靠 mutex 保的 换成什么
“id 不能重复” 唯一索引(v42 就是它)
“余额不能变成负数” 条件更新 UPDATE … WHERE balance >= x
“同一时刻只有一个人能改” 乐观锁 / 版本号 CAS(37 讲那个)
“一批修改要么全成要么全不成” 事务(第五节)
“全局只有一个任务在跑” 分布式锁(Redis / etcd / 数据库行锁)

★ 判断二:唯一索引特别值钱,因为它是【声明式】的。
你写在建表的地方,之后所有写入路径都自动受约束——
包括你还没写的代码、别人手动跑的 SQL、迁移脚本。
if 检查只保护写了它的那一条路径。

★ 判断三:这个"先干、错了再翻译"的写法比"先查再干"既更快(少一次查询)又更对(原子)。
而且它把 41 讲那个 ReplyError 的形状复用了:存储的错误码 → 业务的错误码 → HTTP 状态码,三级翻译。

3.6 Drawing 从"拥有数据"变成"指向数据"

// v31:一个有状态的内存对象
type Drawing struct {
    ID     string
    mutex  sync.Mutex                      // 锁
    shapes map[ShapeID]*shapeOnDrawing     // 数据【在它身上】
    list   shapeOnDrawing                  // 双向链表(维护 z 序)
}

// v42:一个句柄
type Drawing struct {
    id      bson.ObjectId                  // 只有"我是谁"
    session *mgo.Session                   // 和"怎么去拿"
}

每个方法都自己跑一趟数据库,而且是 41 讲那个形状:

c := p.session.Copy()      // ← 借一个连接
defer c.Close()            // ← 用完还回去      ★ 又是 OpenEnv / CloseEnv

★ 这是从"单机"到"存储中间件"最本质的一步:业务对象从"拥有数据"变成"指向数据"。

一个立刻可用的判据:看一个类的字段里有没有业务数据。

它是内存态的——进程一停就没了,多台机器上各有一份(不一致)
没有 它是句柄——随便复制、随便重建,多少台机器都一样

服务端要能水平扩容,业务对象就必须是第二种。
这也解释了"为什么服务端代码看起来到处都在查数据库"——不是写得笨,是它不能有状态。


四 · ★ 换存储把一个功能弄丢了(原文没提)

这是我 diff v41 / v42 时最意外的发现。

// v41(内存)
func (p *Drawing) SetZorder(id ShapeID, zorder string) (err error) {
    switch zorder {
    case "top":    shape.delete(); p.list.insertBack(shape)
    case "bottom": shape.delete(); p.list.insertFront(shape)
    case "front":  shape.moveFront()
    case "back":   shape.moveBack()
    }
}

// v42(mongodb)
func (p *Drawing) SetZorder(id ShapeID, zorder string) (err error) {
    return errNotImpl                      // ← 就这一行
}

为什么偏偏是这个功能?

v31/v41 里 z 序 = 【双向链表】节点的前后关系
                 → 改一次是 O(1),改两个指针就行
                 → 而指针是内存里才有的东西。★ 存储里没有指针。

v42 里 z 序 = drawing 文档里那个  shapes: ["1","2","3"]  数组的下标顺序
             → "把 2 号挪到最前面" = 【重写整个数组】
             → Sync(整体覆盖数组)能保住顺序,单独改一个的 zorder 就麻烦
             → 于是先 errNotImpl 了

★ 这就是"存储即数据结构"的反面:选了存储,你就放弃了一批数据结构。
指针、链表、树、双向引用、原地修改——存储里都没有直接对应物
它们要么被拍平成数组/外键,要么消失。

★ 一个很实用的架构自查(换存储之前做):
把你内存里的核心数据结构写下来,逐个问"它在数据库里长什么样"。

① 有自然对应(map → 表、数组 → 数组字段) 好换
② 要拍平(链表 → 有序数组 / 排序字段) 能换,但某些操作会变贵
③ 根本没有(图、循环引用、指针) 这里就是将来的坑

z 序落在 ②,而 42 讲的选择是"先不做"。这是一个诚实的工程决定,
但如果没人记下来,它就变成一个悄悄丢失的功能。

⚠ 补一句"那正确做法是什么":真实系统里"可排序列表"的标准解法是
给每个元素存一个可比较的 rank(浮点数或字符串),插入时取相邻两个的中间值
(Figma、Notion、Jira 都是这么做的,叫 fractional indexing / LexoRank)。
这样"挪一个元素"只改一行,不用重写数组。v42 那个 shapes 数组是最省事的做法,
代价正好是它不支持"单独挪一个"。


五 · 算法,以及结语那句最重的话

python3 代码/孤立对象与顺序.py

5.1 “算法 = 用户故事背后的实现机制”

原文这段是整讲最好的方法论:

“在架构过程中,需求分析阶段,我们关注用户需求的精确表述,我们会引入角色……以及用户故事
到了详细设计阶段,角色和用户故事就变成了子系统、模块、类或者函数的使用界面(接口)
所以算法,最直白的含义,指的是用户故事背后的实现机制。

串成一条链:

需求分析                     详细设计
────────────────────────    ────────────────────────────
角色(谁在用)           →    接口的【调用者】
用户故事(他要干什么)    →    接口的【方法】
(还没有)              →    方法的【方法体】= 算法

★ 这条链保证"程序是为用户需求服务的"不是一句空话——它是可追溯的。
反过来用它当体检:如果你的某个方法找不到对应的用户故事,它大概不该存在;
如果某个用户故事找不到对应的方法,它大概还没实现。

5.2 用一种"可执行的语言"写伪代码

原文那些算法长这样:

删除 drawing (uid, dgid):
    if db.drawing.remove({_id: dgid, uid: uid}) {
        db.shape.remove({dgid: dgid})
    }

“这些算法的表达整体是一种伪代码。但它也不完全是伪代码。
如果大家用过 mongo 的 shell 的话,其实能够知道这里面的每一条 mongo 数据库操作的代码都是真实有效的。”

★ 这是详细设计文档的一个诀窍:用一种可执行的语言写伪代码。
好处不是"显得专业",是它能被验证——你可以把它粘进 mongo shell 跑一遍。
不可执行的伪代码会和实现漂移,可执行的不会。

Python 世界的同类做法:设计文档里用 doctest;接口设计用真实的类型签名;
数据流设计用真实的 SQL。判据:这段"伪代码"能不能被机器检查?

5.3 ★ 结语那句一带而过的话,其实是整讲最重的一句

“从严谨的角度来说,以上算法中凡是涉及到多次修改操作的,都应该以事务形式来做
……假如第一句 drawing 表的 remove 操作执行成功,但是在此时发生了故障停机事件导致
shape 表的 remove 没有完成……系统残留了一些孤立的 shape 对象,永远都没有机会被清除。”

逐个宕机点跑一遍(脚本实测):

── 原文的顺序(先删 drawing)──
   第一步之前就宕机       → ✓ 库是干净的
   执行完步骤1后宕机      → 垃圾(不可见):孤立 shape ('507f','1'),它的 drawing 已经没了
   全部执行完            → ✓ 库是干净的

── 反过来(先删 shape)──
   执行完步骤1后宕机      → ★ 用户可见的错误:507f 的目录指向不存在的 shape 1

★ 两种顺序都会在宕机时留下不一致,但不一致的【性质】完全不同:

顺序 残留物 危害
先删 drawing(原文) 孤立 shape 用户看不见,只占空间 —— 无害
先删 shape 一张目录指向不存在 shape 的 drawing 用户打开图会报错 —— 有害
┌────────────────────────────────────────────────────────────────┐
│  ★ 没有事务的时候,你不能消除失败,但你可以【选择失败的样子】。  │
│    挑那个"残留物无害"的顺序 —— 这比上事务便宜得多。              │
└────────────────────────────────────────────────────────────────┘

5.4 ★ 一条能背下来的规则,v42 的四处代码全都遵守了它

drawing.shapes 那个数组是一个指针(它指向 shape 表里的行)。于是:

┌───────────────────────────────────────────────┐
│  建的时候:先建【被指的东西】,后建【指针】     │
│  删的时候:先删【指针】,  后删【被指的东西】   │
└───────────────────────────────────────────────┘

为什么? 这两种顺序留下的都是"有对象没指针"(= 不可达的垃圾),
反过来会留下"有指针没对象"(= 悬空引用,读的时候就炸)。
★ 就是 C 语言里"悬空指针比内存泄漏严重得多"那条常识,换了个场景。

逐个对一遍 v42 的四处多步修改:

代码 是否遵守
Drawing.Add shape.Insertdrawing.$push ✓ 先建 shape,后建指针
Drawing.Delete drawing.$pullshape.Remove ✓ 先删指针,后删 shape
Document.Delete drawing.Removeshape.RemoveAll ✓ 先删整个目录,后删 shape
Drawing.Sync 逐个 shape.Upsertdrawing.$set(shapes) ✓ 先 upsert 全部,后覆盖目录

★ 四处全对。而原文一个字都没解释过这条规则——它被"应该以事务形式来做"这句话盖过去了。
脚本还把"违反规则"的版本跑了一遍:先 $pushinsert,宕机就留下一个指向空气的 ID。

5.5 四种解法,事务只是其中一种(而且最贵)

解法 怎么做 代价
事务 两步包在一个事务里 贵,而且有三条限制(见下)
选对顺序 让残留物无害(5.4) 几乎免费,但只降级不消除
补偿 / GC 后台扫孤立对象,定期清理 要写一个清理程序,且要能安全判定
软删除 先标记 deleted,后台真删 简单,但库里长期有垃圾,查询都要过滤

★ 真实系统的组合几乎总是 ② + ③:先靠顺序保证"失败只留垃圾",再用 GC 把垃圾扫掉。
事务留给"残留物有害且换顺序也没法变无害"的场景——典型是转账:钱扣了没到账,任何顺序都有害。

⚠ 关于①,三条一定要知道的限制:

时间线 mongodb 的多文档事务是 4.0(2018) 才有的,而 v42 用的 mgo 驱动(社区版 2018 年就停更)根本做不到★ 也就是说原文说"应该用事务"的时候,这份代码用的驱动没有事务 —— 所以 v42 靠的是 5.4 那个顺序
分片 跨集合事务在分片集群上要跨节点协调(37 讲那个 2PC),每次多一轮网络往返。事务不是"打开一个开关",是"给每次写加一笔税"
边界 事务只能保住一个存储里的原子性。一旦横跨"数据库 + 对象存储"或"数据库 + 消息队列",事务就管不着了——那时能用的还是 ② + ③(39 讲第 4.4 节那条:跨系统原子的代价必然贵过不做)

⚠ 关于③,一个容易被忽略的难点:

GC 扫到 shape ("507f","4"),目录里没有它  →  判定为垃圾
而此刻正好有个请求刚 insert 完 shape、还没 $push  →  ★ GC 把人家刚建的删了

★ 所以 GC 必须有一条「宽限期」:只回收创建时间超过 N 分钟的孤立对象。
这就要求每条记录带一个 createdAt——一个纯粹为了 GC 而存在的字段
(这也是为什么几乎所有正经的表都有 created_at / updated_at。)

5.6 顺带:为什么删除要设计成幂等的

所有"宕机"场景,真实世界里都会跟着一次重试(客户端没收到回复就再发一次):

操作 幂等吗 重试的后果
db.shape.remove({dgid, spid}) 删两次和一次一样
db.drawing.$pull(shapes, spid) 拉两次和一次一样
db.drawing.$push(shapes, spid) 数组里有两个 spid → List 把同一个图形返回两遍
db.shape.insert(...) ~ 第二次报 EEXIST(唯一索引)—— 至少可判定

★ v42 的 Add 用的正是 $push——它不幂等。(mongo 的 $addToSet 才是幂等的那个。)

★ 判断:在一个没有事务的系统里,"每一步都幂等"是"整个操作可重试"的前提。
顺序解决"失败留下什么",幂等解决"重试会不会更糟"。两件事都要做。
39 讲第 4.3 / 6.3 节两次讲过幂等,这里是第三次——它是分布式系统里复现频率最高的一个词。


六 · 网络协议:mock 授权

原文这一节只有半页,但它是 43、44 讲的入口。

Authorization: QPaintStub <uid>
type Env struct {
    restrpc.Env
    UID UserID
}
func (p *Env) OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error {
    auth := req.Header.Get("Authorization")
    pos := strings.Index(auth, " ")
    if pos < 0 || auth[:pos] != "QPaintStub" { return errBadToken }
    uid, err := strconv.Atoi(auth[pos+1:])
    if err != nil { return errBadToken }
    p.UID = UserID(uid)
    return p.Env.OpenEnv(rcvr, w, req)
}

然后每个业务方法只多了一个参数p.doc.Get(id)p.doc.Get(env.UID, id)

★ 这正是 41 讲那个 itfEnv 接缝的兑现(带读-15 · 2.7):
41 讲讲了这个机制但没用它,42 讲第一次往里塞东西。

★ 而 mock 一个东西的价值,是先把它的「接缝」定下来。
这里定下的是:授权发生在每个 handler 之前,结果是一个 env.UID
43、44 讲要做的全部工作,就是换掉这一个函数的实现
这跟 29 讲 mock 整个服务端是同一个手法,尺度小一号。

⚠ 但要看清这个 mock 有多假——这是理解 43/44 讲的关键:

// v42 的浏览器端 dom.js 里,这一行是【硬编码】的:
http.setRequestHeader("Authorization", "QPaintStub 1")

★ 浏览器直接声称"我是 uid=1",服务端就信了。改成 QPaintStub 2 就是别人的数据。
也就是说 v42 的多租户在安全上等于零——它只是把"归属"这条链路的代码写通了。

★ 这个区分很值钱:v42 完成的是【授权的机制】,没完成【授权的可信性】。
43 讲讲清"可信性从哪来",44 讲落地。
而最有意思的是(44 讲带读会讲):真正落地的时候,QPaintStub <uid> 这个格式
一个字都没改——它从"客户端说的话"变成了"网关说的话"。


第三部分 · 引申

⚠ 这一部分不是原文内容。

七 · ⚠ 让接口不被 uid 污染的第三条路

python3 代码/多租户三种做法.py     # 第四节

原文诚实地接受了"接口被污染"(2.3)。它的真问题不是多一个参数,是靠人记得传
新加一个方法忘了带 uid,编译器不说话,测试未必覆盖——就是一次越权

7.1 ★★ 「约定」和「类型约束」差在哪:能不能写出错误的代码

做法 A · 约定(原文的做法):每个方法多传一个 uid,实现里 filter 带上它。

doc.get(7, "507f1f77")               # ✓ 对

问题是这靠人记住。而下面这些都是合法代码,编译器/解释器完全不管

doc.get("507f1f77")                  # 忘了传 uid(签名有默认值就直接跑起来了)
库.find({"_id": dgid})               # ✗✗ 绕过 uid 直接查 —— 完全合法,只是有漏洞

★ 约定的特征:错误的代码写得出来,而且长得很正常。
于是安全取决于"每个人、每次、都记得"——包括三个月后加新接口的那个人。

做法 B · 类型约束(第三条路):不给你 Document,给你一个已经焊死了 uid 的"视角"对象

视角 = doc.as_user(7)      # ★ 从这一刻起,uid=7 焊在这个对象里
视角.get("507f1f77")       # 不用传 uid

关键在于这个对象上根本没有别的方法可调:

视角.get(???, "507f1f77")   # ✗ 没有这个签名,写不出来
视角.查所有人的图()          # ✗ 没有这个方法,写不出来

★★ 所以"把约定变成类型约束"的意思是:
"忘了传 uid"从【一个可能犯的错误】,变成了【一个无法表达的东西】。

这个思路在类型系统里有个名字:make illegal states unrepresentable(让非法状态无法表达)。

7.2 那三个框架的例子,是同一个手法的三个层次

current_user.drawings.find(id)      # Rails

注意它不是Drawing 这张全局表开始查,而是从当前用户开始——.drawings 已经是"这个用户的图"这个集合了。你手上压根没有"全部的图"那个东西。

层次 做法 谁来保证 绕得过吗
对象层 doc.as_user(7) 你自己写的类 拿到底层 doc 就绕过了
ORM 层 Rails current_user.drawings · Django request.user.drawings 框架 写裸 SQL 就绕过了
数据库层 Postgres 行级安全 RLS 数据库自己给每条查询加 filter 写裸 SQL 也绕不过

★ 越往下越难绕过,代价也越大(RLS 要在数据库里配策略、调试更麻烦)。
三者是同一个手法,只是把"保证"这件事放在了不同的高度。

7.3 ⚠ 代价:重点是第二条

① 多一层对象 —— 每次都要 as_user(uid),调用链变长。这是明面上的成本。

② 才是真正要理解的:后台管理、垃圾回收、数据迁移这些操作确实需要跨租户,所以必须留一个"绕过"的口子:

doc.as_user(7).get(id)      # 正常业务:只能看自己的
doc.get(7, id)              # ← 底层方法还在,这就是那个口子

而这个口子就成了新的风险点。

★★ 所以"纪律的位置换了"是这个意思:

之前:几十个业务方法,每一个都要记得带 uid    ← 要看管几十处
之后:只有那几个"绕过"的入口要盯着           ← 要看管几处

纪律没有消失,但需要纪律的地方从几十处缩到了几处。
而"要看管的地方少"本身就是巨大的进步——code review 时你知道该盯哪几行了。

这也是为什么原文选朴素做法不是没道理:在一个小系统里,多一层对象的成本可能大于收益。

7.4 ★★ 这个手法,你在 41 讲已经见过一次

安全设计的通用手法:把"要记得做的事"变成"不做就编不过 / 查不到"。

同一个形状在这门课里出现过三次:

“要记得做的事” 变成了什么
41 讲的 RPC 框架 每个 handler 都要记得写 if err != nil { ReplyError(w, err); return } 你只要 return err框架必然把它翻成状态码——忘不了
本讲 2.4 节(原文做法) 每个查询要记得在 filter 里带 uid 交给数据库的 filter:filter 对了就查不到别人的
本节(第三条路) 同上 交给类型系统:连"不带 uid 查"这个动作都写不出来

★★ 三者共同的形状:把一件"每次都要做对"的事,从【人的记性】挪进【结构】里。
if 是可以忘的,filter 和类型不会忘。

再举两个眼熟的同类:

密码不存明文,只存哈希        →  "泄露明文密码"变成【不可能发生】
用参数化查询而不是拼字符串     →  "SQL 注入"变成【写不出来】

八 · ⚠ 多租户的三种物理落地

第二节说"不该加一层"指的是逻辑 DOM 树。到了物理存储,隔离确实有三档:

方式 长什么样 隔离度 成本 加一个租户
① 一租户一库/一实例 db_tenant_7 最强 最高 建一个库 😰
② 共享库,分 schema schema tenant_7 建一批表
共享表 + uid 列 WHERE uid = 7 最弱(靠代码) 最低 insert 一行 ✓

QPaint 选的是 ③。

★ ③ 的代价很明确:隔离靠的是代码纪律,不是数据库。
漏写一个 WHERE uid=? 就是一次越权——所以第 2.4 节那件事才关键。

★ ① 那种"一租户一库",正是"把多租户做进结构里"的物理版本。
它换来强隔离,代价是运维复杂度随租户数线性增长
改一次表结构要跑 5000 次 migration,加一个索引要跑 5000 次。
这就是 SaaS 世界的默认答案是 ③、只给少数大客户开 ①(叫"混合模型")的原因。

⚠ 顺带一句 2026 年的补充:Postgres 的 RLS 让 ③ 的"靠代码纪律"变成了"靠数据库强制"——
它是这十几年里对 ③ 这条路最大的一次加固。

九 · 收口:这一讲的产出

① 一份【详细设计文档的模板】     使用界面 → 数据结构 → 算法(README_IMPL 的四段)
② 一条【结构 vs 属性】的判断     权限是属性,不是层级
③ 一条【存储即数据结构】的双面     选了存储 = 选定能高效做什么 + 放弃一批数据结构
④ 一条【没有事务时的活法】        顺序决定残留什么,幂等决定能不能重试
⑤ 一个【接缝】                   env.UID —— 43/44 讲要换的就是它背后那一个函数

★ 把 41 和 42 讲连起来看,主线很清楚:

41 讲:把"协议样板"挪出去 → 挪给了 restrpc
42 讲:把"存数据"挪出去   → 挪给了 mongodb
                           ★ 但这次挪出去是【有代价的】:
                             可见性被改了(2.6)、一个功能丢了(四)、
                             mutex 失效了(3.5)、多了一类失败模式(五)

★ 这是 42 讲比 41 讲厚的根本原因:41 讲挪走的是纯样板(挪走没有损失),
42 讲挪走的是"状态"——而状态一旦离开进程,它就带回来一整套新问题。

这正是 36 讲那句"存储中间件是服务端最难的部分"的具体样子。


验收

地基(一)

# 问题 答案在
1 v42 新增的那份 README_IMPL 有哪四段?为什么必须是这个顺序?
2 41 讲和 42 讲改的文件正好是"对称的两半",各改了什么? 1.1

原文(二~六)

# 问题 答案在
3 多租户"加一层"会在哪一步结构性地崩掉?这条判断的通用形式是什么? 2.2
4 为什么 findOne({_id, uid}) 比"先读出来再判归属"更安全? 2.4
5 Shape 完整的约束是哪四条?为什么会有第 2、3 条? 2.5
6 为什么 42 讲把 pathData 改成 PathData?这暴露了什么更一般的规律? 2.6
7 v42 怎么把"约束"变成可执行的东西?那个测试断言的是什么? 2.7
8 "选 mongodb 因为 Schema 开放"这个推理,今天要打几折?更准的判断是什么? 3.2
9 联合索引 (dgid, spid) 的顺序为什么不能反? 3.4
10 为什么 v42 的 Add 里没有"先查一下有没有"?唯一索引凭什么比 if 强? 3.5
11 换成 mongodb 之后哪个功能被弄丢了?为什么偏偏是它?
12 "顺序决定残留什么"那条规则怎么说?v42 的四处代码遵守了吗? 5.4
13 原文说"应该用事务",为什么 v42 没用?除了事务还有哪三条路? 5.5
14 v42 的多租户在安全上等于零,为什么?它完成的是什么?

引申(七~九)

# 问题 答案在
15 怎么让"忘了传 uid"在物理上不可能发生?它的代价是什么?
16 多租户的三种物理落地各是什么?为什么 SaaS 的默认答案是第三种?

答案

1.逻辑 DOM 结构(有什么)② 使用界面(能被怎么用,含语言之外的约束)③ 数据结构(表 + 索引)④ 实现逻辑/算法(每个用户故事的伪代码)。
必须是这个顺序,因为每一步给下一步定题:①→② 决定接口围绕哪些实体写;②→③ 表和索引由"要跑哪些查询"决定;③→④ 算法只能用存储支持的操作拼出来。
反过来做(先选数据库再想接口)会得到"接口长得像表的 CRUD、业务语义全在调用方"的系统。这个顺序保证了原文那句"使用界面应该自然体现业务需求"——因为界面是在还没想过数据库的时候定下来的。

2. 41 讲:service.go 重写(286→169),drawing.go / shape.go 一字节没改
42 讲:drawing.go 几乎全部重写(241→270),shape.go 行数不变但每一行都改了(大小写 + bson 标签),shape_test.go 34→78,service.go 只是加了 Env 和 uid 参数。
每次只动一层——这本身就是分层的回报。

3. 在**“一张图分享给同事”**那一步:一个 drawing 要挂在两个 User 下,四层树是结构性地崩掉(一个节点两个父亲),不是"改起来麻烦"而是"它表达不了"。而"分享"几乎没有一个协作产品逃得掉。
通用形式:层级表达「从属」,多租户表达「归属权/可见性」;归属权是一个属性,不是一层结构。
更该背的一条:把权限编码进结构,结构就会随权限模型一起变,而权限模型是最爱变的东西(个人→团队→组织→共享→公开链接)。

4. 因为 403 和 404 的差别会回答一个攻击者没资格问的问题:“这个 ID 存在吗?”
先读后判:别人的 → 403,不存在的 → 404,两个答案不一样,于是拿一批 ID 去扫,403 的就是真实存在的资源(存在性泄漏,属于 OWASP 的 IDOR)。
条件里带 uid:两种情况都是 404,"没权限"和"不存在"在代码层面就是同一条路径,你想泄漏也泄漏不了
准则:租户条件应该出现在 filter 里,不该出现在 if 里(Django 的 filter(owner=user)、Postgres 的 RLS 都是这条)。

5. ① 语言层:实现 GetID();② json.Marshal 结果符合 API 层预期;③ bson.Marshal 结果符合 mongodb 预期(42 讲新增);④ 必须能被反序列化回来,而这一步是封闭的(原文完全没提)。
第 2、3 条的形状是一样的:Shape 实例被"直接"交给外部系统,于是那个外部系统的规矩变成了 Shape 的约束。"直接"是关键词——加一层 DTO,约束就不会传染进来。v42 选了不加,省了代码,代价是 Model 被两个外部系统同时绑住。

6. 因为 Go 的序列化库只能看见导出(大写开头)的类型和字段,bson 要把它们写进 mongodb 就必须能看见。
一般规律:你的类型一旦要跨出进程(存盘、上网、进消息队列),字段名就变成对外契约的一部分,改一个字段名 = 一次数据迁移。
而它收回了 26~30 讲那个自由:凡是要落盘的东西,都不能再是"内部细节"。
最阴的一点是序列化库跳过看不见的字段是不报错的——数据静默丢失。

7. 用一个"绕一圈回来必须相等"的测试:bson.Marshal → bson.Unmarshal → json.Marshal,然后要求结果和直接 json.Marshal 得到一模一样的字符串
它断言的是:过一趟数据库再回来,API 看到的东西必须和没过数据库时完全相同——一句话同时钉住约束二、约束三,还钉住"两者不能漂移"。
强在端到端(任何一环出错都被抓住)、有字面量(那串 JSON 就是接口文档)、不可绕过(加图形必须也加测试)。代价是脆,所以只适合放在类型定义这一层。

8. 要打折,三条理由:① 关系型也能 schemaless(Postgres 的 JSONB,还能对里面的字段建索引);② v42 实际只有两种访问方式(按 (dgid,spid) 取一条、按 dgid 取一批)——它就是一张 KV 表,用什么都不吃亏;③ 真实选型理由常常是"团队熟、不想写 migration"。
更准的判断:如果一张表里唯一需要被查询的东西是主键、其余内容是黑盒,那它就是一张 KV 表。真正决定选型的是"你要不要对黑盒里的字段做查询/排序/聚合"。

9. 因为索引物理上是一张按 key 排序的表,查询能不能用上它取决于条件是不是这个排序的最左前缀
(dgid, spid):List(按 dgid)扫 50 行 ✓,Get 扫 1 行 ✓ —— 一个索引支撑两个查询
(spid, dgid):Get ✓,但 List 要全表扫 1000 行 ✗。
规则:索引 (A,B,C) 服务 A / A,B / A,B,C,不服务 B / C把"总是出现"的字段放前面——dgid 每个 API 都带。

10. 因为 v31 那个"先查再写 + mutex"的写法在多台机器上失效(mutex 各锁自己的)。脚本实测:两个进程并发,两个都插成功了。
唯一索引凭三点比 if 强:① 它是原子的(数据库自己保证);② 它是声明式的——写在建表处,所有写入路径自动受约束,包括你还没写的代码、别人手跑的 SQL、迁移脚本,而 if 只保护写了它的那一条路径;③ 少一次查询,更快
v42 的写法是"直接 Insert,让数据库报错,再 mgo.IsDup(err) → syscall.EEXIST 翻译"——存储错误码 → 业务错误码 → HTTP 状态码,三级翻译。
更一般:进程内的互斥在多机上一律失效,靠 mutex 保住的每一个不变量都要重新找分布式的靠山(唯一索引 / 条件更新 / 乐观锁 / 事务 / 分布式锁)。

11. SetZorder 变成了 return errNotImpl
因为 z 序在内存版里是用双向链表表达的,而链表由指针组成,存储里没有指针。换成 shapes: ["1","2","3"] 数组之后,“挪一个元素”= 重写整个数组——Sync(整体覆盖)没问题,单独改一个的 zorder 就麻烦,于是先不做了。
这是**"存储即数据结构"的反面:选了存储,你就放弃了一批数据结构**(指针、链表、树、双向引用、原地修改)。
(正确做法是 fractional indexing / LexoRank:给每个元素存一个可比较的 rank,插入取相邻两者的中间值——Figma、Notion、Jira 都这么做。)

12. 建的时候先建"被指的东西"、后建"指针";删的时候先删"指针"、后删"被指的东西"。
因为这两种顺序留下的都是"有对象没指针"(不可达的垃圾,无害),反过来留下"有指针没对象"(悬空引用,读就炸)。就是"悬空指针比内存泄漏严重得多"换了个场景。
v42 四处全都遵守Add(insert shape → push)、Deletepush)、`Delete`(pull → remove shape)、Document.Delete(remove drawing → removeAll shape)、Sync(upsert 全部 → $set 目录)。而原文一个字都没解释过这条规则。

13. 因为 mongodb 的多文档事务是 4.0(2018) 才有,而 v42 用的 mgo 驱动做不到——原文说"应该用事务"的时候,这份代码用的驱动没有事务。所以它靠的是 5.4 那个顺序。
另外三条路:② 选对顺序(几乎免费,只降级不消除)③ 补偿/GC(后台扫孤立对象,必须有宽限期,所以每条记录要有 createdAt)④ 软删除
真实系统几乎总是 ② + ③;事务留给"任何顺序都有害"的场景(转账)。而事务本身还有两条限制:分片下要 2PC,等于给每次写加一笔税它只能保住一个存储内的原子性

14. 因为 v42 的浏览器端 dom.jshttp.setRequestHeader("Authorization", "QPaintStub 1")硬编码的——浏览器直接声称"我是 uid=1",服务端就信了。改成 QPaintStub 2 就是别人的数据。
它完成的是授权的机制(uid 怎么流进业务层、归属怎么校验),没完成授权的可信性。43 讲讲可信性从哪来,44 讲落地。
而 44 讲落地时 QPaintStub <uid> 这个格式一个字都没改——它从"客户端说的话"变成了"网关说的话"。

15. 把 uid 提前绑定成一个"用户视角"对象:doc.as_user(7).get(dgid)。你手上只有这个对象,就没有一个能不带 uid 查询的方法可调——把约定变成类型约束
框架里的同一形状:Rails 的 current_user.drawings.find(id)、Django 的 request.user.drawings.get(...)、Postgres 的 RLS。
代价:① 多一层对象;② 后台管理/GC/迁移这类确实要跨租户的操作要开一个"绕过"的口子,那个口子成了新的风险点——纪律没消失,只是换了位置。
但两种做法都比"每个人记得写 if"可靠。安全设计的通用手法就是:把"要记得做的事"变成"不做就编不过 / 查不到"。

16.一租户一库/一实例(最强隔离、最贵、加租户要建库)② 共享库分 schema(中)③ 共享表 + uid 列(最弱、最便宜、加租户就 insert 一行)。QPaint 选 ③。
默认是 ③,因为 ① 的运维复杂度随租户数线性增长——改一次表结构要跑 5000 次 migration,加一个索引要跑 5000 次。所以只给少数大客户开 ①(混合模型)。
③ 的代价是"隔离靠代码纪律",而 Postgres 的 RLS 是这条路十几年来最大的一次加固(把纪律交给数据库强制)。


下一步

带读 17 · 43 讲:帐号与授权(OAuth 2.0 的零点)

42 讲:多租户的【机制】通了,但授权是假的(浏览器自己声称 uid=1)
43 讲:纯概念一讲,没有代码。要回答的是"可信性从哪来"
        帐号是什么       —— 标识符 / 凭据 / 属性,三个东西常被混成一个
        授权是什么       —— 为什么"帐号 : 授权"是一对多
        为什么少传密码    —— 原文说"泄漏概率",其实还有一条性能上的硬理由
        OAuth 2.0       —— ★ 先退回零点:它到底在解决什么问题?
        ★ 原文列了六种模式,但没解释最关键的那个设计:为什么要有"授权码"这一步

回看:
带读 20 · 45 讲这四段在整个流程里的位置 · 需求分析→概要设计→详细设计 · 一张能抄的清单)
带读 00 · Go 底子首字母大小写=可见性——本讲 2.6 节那批改动全靠它·struct 嵌入·字段标签)
带读 15 · 41 讲itfEnv 那个接缝——第六节兑现的就是它 · 分层是否成立的指标)
笔记 · 36 讲存储即数据结构 · ok 不许是假的)
带读 11 · 37 讲事务 · 跨分片的代价 · 乐观锁——第 5.5 节全靠它)
带读 13 · 39 讲check-then-act 那条缝 · 幂等——第 3.5、5.6 节接的是它)

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关