案例 02 · PatchDesk:给 20 人团队用的轻量工单 SaaS
一句话点题:本案例练的是「普通 SaaS 的克制与边界」——一个工单系统看起来只是增删改查,真正难的却是租户隔离、权限边界、搜索报表、通知副作用和后续演进不腐化。
🧪 案例篇第 2 篇 · 本案例只练一件事
练模块化单体 + 多租户 SaaS下的架构判断:什么时候不要拆微服务,什么时候必须把租户隔离做成结构,什么时候搜索 / 报表 / 通知该从主请求链路里挪出去。
读完你应该能 本案例靠什么练 判断为什么 20 人团队的 SaaS 不该一上来微服务 从团队规模、租户数、QPS 算出复杂度预算 说清多租户隔离的三种方案取舍 对比共享表、独立 schema、独立库 把权限、时间线、通知放进架构而不是补丁 用 RBAC、工单事件、Outbox 和异步通知兜底 看懂普通 CRUD 系统什么时候该演进 用搜索慢、报表拖库、通知失败这些信号触发升级 重要提醒:下面是教学化案例,不是某个 SaaS 产品的内部图纸。 数字用于数量级推理,目的是练判断,不是给出唯一答案。
开场:为什么普通工单系统也值得写一章
因为大多数团队真正会做的,不是抢票、支付、网约车这种高压系统,而是一个个看起来普通的 SaaS 后台。
PatchDesk 是一个给 20 人以内团队用的轻量工单系统。客户可以提交问题,团队成员可以分派负责人、评论、改状态、发邮件通知、看简单报表。
第一眼看,它很普通:
- 租户注册;
- 成员管理;
- 工单 CRUD;
- 评论和附件;
- 邮件通知;
- 基础搜索和报表。
但普通不等于没有架构。这个系统真正会咬人的地方不是「怎么写一个新增工单接口」,而是:
一套系统服务很多家公司时,如何让每家公司只看见自己的数据,让不同角色只做自己该做的事,并且别为了将来可能的规模过早背上分布式复杂度。
这章和上一章 StarArena 正好相反:上一章是「压力太大,不得不加复杂度」;这一章是「压力还没到,先别乱加复杂度」。
读前小词典
这篇会反复出现几个词,先用人话对齐一下:
| 词 | 人话解释 |
|---|---|
| CRUD | Create、Read、Update、Delete,也就是新增、查询、修改、删除。很多后台系统表面上都是 CRUD。 |
| QPS | Queries Per Second,每秒请求数。这里用来粗略估算系统压力。 |
| P95 | 95% 的请求都能在这个时间内完成。比如 P95 < 300ms,意思是 100 次请求里大约 95 次小于 300 毫秒。 |
| SaaS | Software as a Service,一套在线软件卖给很多客户用。 |
| 租户 | SaaS 里的一个客户组织,比如 A 公司、B 公司。每个租户都应该只看到自己的数据。 |
| 多租户 | 一套系统同时服务多个租户。难点是成本低,但不能串数据。 |
| RBAC | Role-Based Access Control,基于角色的权限控制。比如管理员、客服、只读成员。 |
| 模块化单体 | 还是一个应用一起部署,但内部按业务边界分模块,比如租户、工单、通知、报表。 |
| 审计日志 | 记录谁在什么时候做了什么。出问题时能追溯,合规时能证明。 |
| 读模型 | 为查询 / 报表单独整理的一份数据视图,避免复杂查询直接拖垮主库。 |
| 异步任务 | 不需要让用户等着完成的事,比如发邮件、生成报表,丢到后台慢慢做。 |
| Outbox | 可以理解成「待投递事件表」。业务写入成功时,顺手把要发的通知 / 索引事件也写进数据库,后台再慢慢投递。 |
| SLA | Service Level Agreement,服务承诺时间。比如重要工单 2 小时内必须响应。 |
| tenant_id | 每条数据上标记「属于哪个租户」的字段。忘了带它,就可能跨租户泄露。 |
一、起始状态:先把产品做对,别先把架构做大
PatchDesk 的第一版目标很朴素:让小团队能把客户问题接住、分给人、跟进到关闭。
起始阶段的约束大概是这样:
| 维度 | 起始阶段 |
|---|---|
| 租户数 | 50 个以内 |
| 单租户成员 | 5~20 人 |
| 每租户每日工单 | 20~200 条 |
| 峰值读请求 | 50~150 QPS |
| 峰值写请求 | 10~30 QPS |
| 团队规模 | 3~6 名工程师 |
| 核心目标 | 快速上线,验证是否有人愿意用 |
| 最不能错 | A 租户不能看到 B 租户的数据;普通成员不能越权改配置 |
这时最合理的架构不是微服务,而是模块化单体 + 一个关系型数据库 + 一个简单后台任务队列:
浏览器 / 移动端
│
▼
┌──────────────────────────────────────────┐
│ PatchDesk 单体应用 │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 租户/成员 │ │ 工单/评论 │ │ 权限/RBAC │ │
│ └────────┘ └────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 通知任务 │ │ 搜索/报表 │ │ 审计日志 │ │
│ └────────┘ └────────┘ └────────┘ │
└───────────────┬──────────────────────────┘
│
┌───────┴────────┐
▼ ▼
┌────────────┐ ┌────────────┐
│ 主数据库 │ │ 后台任务队列 │
│ tickets 等 │ │ email/report│
└────────────┘ └────────────┘这不是「没有架构」。恰恰相反,它把最重要的边界先想清楚了,只是先不拆成多个部署单元。
二、量化假设:它不是被 QPS 压垮,而是被边界压垮
先算一笔账。假设 PatchDesk 上线半年后,已经不是玩具项目,但仍然是轻量 SaaS:
租户数:200
活跃租户:50
每租户成员:5~30 人
总工单新增:5,000~20,000 条/天
每条工单平均事件:6~10 条评论 / 状态变更 / 分派记录
峰值读请求:100~300 QPS
峰值写请求:20~80 QPS
单附件上限:25MB
月新增附件:100GB 级别
每次工单更新触发通知:1~5 条邮件 / webhook / 站内消息
目标:创建 / 更新 P95 < 300ms,列表查询 P95 < 700ms
异步目标:通知、webhook、搜索索引通常 30 秒内可见这个数量级对一个普通关系型数据库和一个模块化单体来说,并不吓人。
真正危险的是另外三件事:
- 租户隔离:任何一个查询漏掉
tenant_id,就可能让 A 公司看到 B 公司的工单。 - 权限边界:普通成员能不能删除工单?外包成员能不能看财务类工单?团队负责人能不能改全局配置?
- 慢副作用:发邮件、生成报表、重建搜索索引,如果都卡在用户请求里,体验会变慢,失败还难恢复。
- 完整历史:只存
tickets.status这种当前状态不够。谁改了状态、谁重新分派、哪次通知失败,都要能追溯。
所以 PatchDesk 的架构重心不是「怎么抗十万 QPS」,而是:
把租户、权限、状态历史、慢副作用做成结构,不要靠每个开发者每次都记得。
三、触发信号:什么时候说明第一版开始不够用
第一版跑起来后,不要凭感觉升级。看这些信号:
| 信号 | 表现 | 为什么这是架构问题 |
|---|---|---|
| 出现租户串扰风险 | 某个列表接口忘了带 tenant_id,测试才发现 | 租户隔离依赖人工纪律,不是结构强制 |
| 权限判断散落各处 | 每个接口自己写一段 if role == ... | 规则重复且不一致,越权风险会越来越高 |
| 工单搜索变慢 | 关键词搜索把主库 CPU 打高 | 搜索读法和事务写法冲突,需要独立索引或读模型 |
| 报表拖慢主请求 | 管理员导出月报时,普通工单列表也变慢 | 大查询和在线请求抢同一份数据库资源 |
| 邮件通知失败难补 | 创建工单成功,但邮件服务挂了,没人知道通知没发 | 副作用没有 Outbox / 异步任务和投递状态追踪 |
| 邮件入口重复投递 | 同一封客户邮件被投递两次,生成两张工单 | 外部输入没有幂等键,重复消息无法识别 |
| 审计补不上 | 客户问「谁删了这个工单」,系统答不上来 | 审计日志不是事后开关,要在关键动作里结构化记录 |
这些信号不是在说「要不要上微服务」。它们在说:边界和副作用开始靠人肉维持了。
四、核心矛盾:CRUD 不难,谁能看谁能改才难
PatchDesk 的核心对象只有几个:
- 租户 / 成员:哪个公司,哪些人。
- 工单 / 评论 / 附件 / 工单事件:客户问题、处理过程和完整时间线。
- 权限 / 审计 / 通知:谁能做什么,做了什么,要通知谁。
如果只看增删改查,它很简单:
用户请求 → 查工单 → 改状态 → 写评论 → 发通知但真正的系统必须在每一步都回答:
- 这个用户属于哪个租户?
- 他有没有权限看这张工单?
- 这次修改要不要写审计日志?
- 通知失败能不能补发?
- 搜索 / 报表能不能不拖慢主链路?
所以新的架构命题变成:
把「租户隔离」「权限判断」「慢副作用」从散落在接口里的代码,收口成系统的固定结构。
还有一个很容易被低估的点:工单系统不要只保存最终状态。
如果只有这一列:
tickets(id, tenant_id, title, status, assignee_id, ...)你只能知道「现在是什么状态」。但客服系统真正需要的是「怎么走到这个状态」:
- 谁把工单从
open改成pending? - 谁把负责人从 A 改成 B?
- 客户补充了哪条信息?
- SLA 提醒有没有触发?
- 哪些通知发成功了,哪些失败了?
所以更稳的结构是:
tickets(id, tenant_id, status, assignee_id, ...)
ticket_events(id, tenant_id, ticket_id, actor_id, type, payload, created_at)
outbox_events(id, tenant_id, aggregate_type, aggregate_id, event_type, payload, status)tickets 存当前态,让列表和详情页快;ticket_events 存追加式时间线,让审计和复盘有依据;outbox_events 存待投递副作用,让邮件、webhook、搜索索引失败后可以重试。
五、方案推演:多租户到底怎么隔离
这是本案例最重要的决策。SaaS 系统做多租户,常见有三条路。
方案 A:共享库共享表,每条数据带 tenant_id
tickets(id, tenant_id, title, status, assignee_id, ...)
comments(id, tenant_id, ticket_id, body, ...)| 优点 | 代价 |
|---|---|
| 成本最低,运维简单,适合小客户多的 SaaS | 隔离强度最弱,漏一个 tenant_id 就可能串数据 |
| 容易做跨租户运营统计 | 需要平台层强制注入租户条件,不能靠人记 |
方案 B:共享库,每个租户一个 schema
tenant_a.tickets
tenant_b.tickets| 优点 | 代价 |
|---|---|
| 隔离比共享表强,单租户导出 / 迁移更清楚 | schema 数量多后迁移、运维、版本升级更麻烦 |
| 企业客户心理上更安心 | 小租户很多时管理成本上升 |
方案 C:每个租户独立数据库
tenant_a_db
tenant_b_db| 优点 | 代价 |
|---|---|
| 隔离最强,爆炸半径小 | 成本和运维随租户数线性上涨 |
| 适合大客户、强合规、数据驻留要求 | MVP 阶段会极大拖慢开发和运维 |
PatchDesk 第一阶段选择:共享库共享表,但租户隔离必须由结构强制。
关键不在「用不用 tenant_id」,而在于:
不能要求每个开发者每次都记得加
tenant_id;必须让查询入口、ORM / 数据访问层、测试一起强制它。
未来如果出现高价值企业客户,可以把少数租户迁到独立 schema 或独立库。但这应该由客户价值、合规、风险触发,不是第一天就全量上。
六、关键架构决策:用 ADR 把为什么留下来
ADR 是 Architecture Decision Record,可以理解成「架构决策记录」。普通 SaaS 最容易在后面被人问:「当初为什么不上微服务?为什么租户共表?为什么搜索要异步?」这些都应该提前写下来。
ADR-01:先做模块化单体,不拆微服务
- 背景:团队只有 3~6 名工程师,租户数和 QPS 都还低,业务边界也会随着产品验证变化。
- 选择:一个部署单元,内部按租户、工单、权限、通知、报表划模块边界。
- 放弃:放弃服务独立部署和独立扩容。
- 换来:开发、调试、事务、发布都简单,团队能把注意力放在产品和边界上。
- 风险:如果模块边界不强制,单体会慢慢变成一团泥。
- 复审条件:当两个以上团队长期在同一模块互相阻塞,或某个模块确实需要独立伸缩 / 独立发布时,再评估拆服务。
ADR-02:采用共享表多租户,但租户过滤必须结构化
- 背景:PatchDesk 面向大量小团队,独立库成本过高;但租户串扰是最高级别事故。
- 选择:核心表带
tenant_id,所有数据访问必须经过租户上下文;自动化测试覆盖跨租户不可见。 - 放弃:放弃物理隔离带来的强安全感。
- 换来:低成本、低运维复杂度,适合大量小租户。
- 风险:如果有人绕过数据访问层直查数据库,仍可能漏隔离。
- 复审条件:出现强合规客户、数据驻留要求、或租户串扰风险无法通过平台层控制时,考虑独立 schema / 独立库。
ADR-03:工单变更写状态机 + 时间线,通知等副作用走 Outbox / 队列
- 背景:工单状态、评论、分派、SLA、通知都围绕「变更事件」展开;外部邮件和 webhook 可能失败、重复、延迟。
- 选择:一次工单变更在事务内更新当前态,追加
ticket_events,同时写入outbox_events;后台 worker 再负责发邮件、webhook、索引、SLA 提醒。 - 放弃:放弃第一天就拥有完整通知平台、搜索集群、数据仓库。
- 换来:主请求链路短、历史可审计、副作用可重试,复杂度随信号逐步增加。
- 风险:异步意味着短暂延迟,用户可能几秒后才收到邮件或看到搜索结果。
- 复审条件:搜索 P95 连续超标、报表查询影响主库、通知失败率不可接受时,升级对应旁路。
七、演进后的结构与数据流
下面只画 PatchDesk 的核心结构。它依然是一个模块化单体,不是微服务。
起始路径
用户请求
└─▶ 工单接口
└─▶ 查 / 改 tickets 表
└─▶ 同步发邮件
└─▶ 返回结果问题是:租户判断、权限判断、通知副作用都挤在接口里,越写越散。
演进后的结构
浏览器 / 移动端
│
▼
┌──────────────────────────────────────────────┐
│ PatchDesk 模块化单体 │
│ │
│ ┌──────────────┐ │
│ │ 请求入口 │ ← 认证、租户上下文、限流 │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 权限 / RBAC │──▶│ 工单模块 │ │
│ └──────────────┘ │ ticket/comment│ │
│ └──────┬───────┘ │
│ │ │
│ ┌──────────────┐ │ │
│ │ 审计日志 │◀─────────┘ │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 工单时间线/Outbox│ ← ticket_events/outbox_events│
│ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 通知任务 │ │ 搜索 / 报表读模型 │ │
│ └──────────────┘ └──────────────┘ │
└───────────────┬──────────────────────────────┘
│
┌───────┴───────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 主数据库 │ │ 后台任务队列 │
│ tenant_id 强制 │ │ email/index/report│
└──────────────┘ └──────────────┘这张图的核心变化不是「拆成服务」,而是结构变清楚了:
- 请求入口统一建立租户上下文。
- 权限模块统一判断谁能看、谁能改。
- 工单模块只处理工单业务,不散落通知和报表细节。
- 审计日志记录关键动作,不是事后补丁。
- 通知 / 搜索 / 报表先作为旁路异步化,不要拖慢主请求。
跟一次「创建工单」走到底
1. 用户提交新工单。
2. 请求入口完成认证,拿到 user_id 和 tenant_id。
3. 权限模块判断:这个用户能不能在该租户下创建工单。
4. 工单模块写入 tickets 表,数据自动带 tenant_id。
5. 同一个事务里追加 `ticket_events`:谁创建了哪张工单。
6. 同一个事务里写入 `outbox_events`:需要发哪些通知、更新哪些索引。
7. 提交成功后,后台 worker 读取 Outbox,异步发送邮件 / webhook / 站内消息。
8. 搜索索引任务异步更新关键词索引。
9. 用户立即看到创建成功,不等待邮件和索引完成。这里的关键点:
tenant_id不是靠接口作者手写,而是从请求上下文进入数据访问层。- 工单状态流转由状态机约束,比如已关闭工单不能随便跳回处理中。
- 权限判断不散落在每个接口里,而是收口到统一模块。
- 发通知和更新搜索索引失败,不应该让「创建工单」回滚;Outbox 负责后续重试。
- 审计日志要跟关键业务写入靠近,否则事后补不全。
八、坏了怎么办:故障场景与兜底
| 故障 | 直接后果 | 检测方式 | 架构兜底 |
|---|---|---|---|
查询漏带 tenant_id | A 租户可能看到 B 租户数据 | 跨租户自动化测试、代码扫描、数据访问层审查 | 所有查询强制经过租户上下文;禁止绕过数据访问层 |
| 权限规则散落 | 某些接口能越权修改工单 | 权限矩阵测试、审计异常 | RBAC 收口到统一模块;关键动作写审计 |
| 邮件服务故障 | 工单创建成功但没人收到通知 | 通知任务失败率、死信队列 | 异步重试、投递状态可查、必要时人工补发 |
| 邮件入口重复投递 | 同一封邮件生成多个工单 / 评论 | 重复 message-id、重复内容告警 | 用邮件 message-id 或外部事件 ID 做幂等键 |
| SLA 定时任务漏跑 | 超时工单没有升级 | SLA 延迟指标、周期性对账 | 定时扫描可重放,任务状态入库 |
| 报表查询拖慢主库 | 普通工单列表也变慢 | 慢查询、主库 CPU、报表耗时 | 报表读模型、只读副本、离线生成 |
| 搜索索引延迟 | 新工单短时间内搜不到 | 索引延迟指标 | 页面提示短暂延迟;后台重试补索引 |
| 只存当前状态不存事件 | 无法追责、无法复盘、审计缺失 | 客户追问时查不到历史 | 当前态 + 追加式 ticket_events |
| 附件塞进数据库 | 备份膨胀、查询变慢、迁移困难 | 数据库体积异常、备份变慢 | 附件放对象存储,数据库只存元数据和权限 |
| 审计日志缺失 | 出事后无法追责 | 审计完整性检查 | 关键动作与业务写入同事务,普通查看类动作异步记录 |
普通 SaaS 的成熟度,不是看它有多少服务,而是看这些边界有没有被结构固定住。
📌 拿模板验证这次推演
本案例不是重写标准 Web 应用模板,而是把「普通 SaaS」里最容易被低估的几条边界拿出来细推。
| 可复用模板 | 本案例复用什么 | 本案例重点补什么 |
|---|---|---|
| 标准 Web 应用 | 单体应用、关系型数据库、缓存 / 队列按需增加 | 用具体 SaaS 场景说明为什么先单体不是没设计,而是克制 |
| 移动 App | 客户端身份、弱网、推送入口 | 本案例不展开移动端,只把它当 PatchDesk 的一个入口 |
| 通知 / 推送系统 | 异步通知、重试、投递状态 | 说明发邮件 / 站内消息为什么不能卡在主请求里 |
| 安全与多租户 | 租户隔离、最小权限、审计 | 把「不能串租户」落成数据访问层和测试的结构约束 |
读法建议:先读本章,再回看 标准 Web 应用模板。你会更容易看懂「单体优先」不是偷懒,而是把复杂度预算留给真正会咬人的地方。
🎯 随堂检验
- A因为微服务永远不好
- B因为团队小、QPS 低、业务边界还在变化,微服务会提前引入分布式复杂度,却换不来对应收益
- C因为单体可以完全不用设计模块边界
- A数据库会因为多一个 tenant_id 字段立刻变慢
- B隔离依赖每条查询都正确带 tenant_id,一处遗漏就可能跨租户泄露
- C每个租户都必须独立部署一套应用
- A因为通知不重要
- B因为外部系统可能变慢、失败、重复,Outbox 和队列能让副作用可重试、可恢复
- C因为数据库不能存通知记录
本案例小结
- 普通 SaaS 不是没有架构,只是不要过度设计。 PatchDesk 第一版最该做的是模块边界、租户隔离、权限收口,不是微服务。
- 它不会先被 QPS 压垮,会先被边界压垮。 200 个租户的工单量对单体和关系库不吓人;真正危险的是串租户、越权、审计缺失。
- 多租户隔离不能靠人记。 共享表可以用,但
tenant_id必须由结构强制:请求上下文、数据访问层、自动化测试一起兜住。 - 工单不是只有当前状态,还要有时间线。
tickets存当前态,ticket_events存历史,outbox_events存待投递副作用。 - 慢副作用要挪出主链路。 发通知、更新搜索索引、生成报表都不该让用户等;异步化不是为了炫技,是为了主链路短、失败可补。
- 先模块化单体,再按信号演进。 等搜索慢、报表拖库、团队互相阻塞这些信号真的出现,再加读模型、只读副本或拆服务。
承上启下:这一章把 标准 Web 应用模板 里的「克制」落到一个具体 SaaS。下一章如果继续写 AI / RAG 类案例,就会换另一种压力:不是租户和权限,而是答案可信、检索质量、成本和提示注入。
相关链接
- 模板对照:标准 Web 应用 · 移动 App · 通知 / 推送系统
- 方法论:02 · 架构师的思考框架 · 07 · 从 0 到 1 设计一个系统 · 08 · 架构决策记录与演进
- 硬骨头:14 · 演进与拆分大型系统 · 15 · 组织即架构 · 16 · 安全与多租户架构
💬 评论