三十秒版:这一讲在干嘛
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.go 的 Document |
(不出现,它是根) |
| Drawing | drawing.go 的 Drawing |
/drawings/{dgid} |
| Shape | shape.go 的 Shape |
/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.md 和 shape_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 → v42 的 shape.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"`
}
三处改动,各有各的原因:
① 类型名从小写改成大写
shapeBase → ShapeBase、lineData → LineData、rectData → RectData……
因为 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 行 ✗(全表) |
取 dg07 的 sp013(Get) |
扫 1 行 ✓ | 扫 1 行 ✓ |
找所有叫 sp013 的(跨图查) |
扫 1000 行 ✗ | 扫 20 行 ✓ |
★
(dgid, spid)这个顺序,一个索引同时支撑了List和Get;反过来只支撑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.Insert → drawing.$push |
✓ 先建 shape,后建指针 |
Drawing.Delete |
drawing.$pull → shape.Remove |
✓ 先删指针,后删 shape |
Document.Delete |
drawing.Remove → shape.RemoveAll |
✓ 先删整个目录,后删 shape |
Drawing.Sync |
逐个 shape.Upsert → drawing.$set(shapes) |
✓ 先 upsert 全部,后覆盖目录 |
★ 四处全对。而原文一个字都没解释过这条规则——它被"应该以事务形式来做"这句话盖过去了。
脚本还把"违反规则"的版本跑了一遍:先$push后insert,宕机就留下一个指向空气的 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 → 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.js 里 http.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 节接的是它)
