# 案例 02 · PatchDesk:给 20 人团队用的轻量工单 SaaS > 一句话点题:**本案例练的是「普通 SaaS 的克制与边界」——一个工单系统看起来只是增删改查,真正难的却是租户隔离、权限边界、搜索报表、通知副作用和后续演进不腐化。** --- > **🧪 案例篇第 2 篇 · 本案例只练一件事** > > 练**模块化单体 + 多租户 SaaS**下的架构判断:什么时候不要拆微服务,什么时候必须把租户隔离做成结构,什么时候搜索 / 报表 / 通知该从主请求链路里挪出去。 > > | 读完你应该能 | 本案例靠什么练 | > |---|---| > | 判断为什么 20 人团队的 SaaS 不该一上来微服务 | 从团队规模、租户数、QPS 算出复杂度预算 | > | 说清多租户隔离的三种方案取舍 | 对比共享表、独立 schema、独立库 | > | 把权限、时间线、通知放进架构而不是补丁 | 用 RBAC、工单事件、Outbox 和异步通知兜底 | > | 看懂普通 CRUD 系统什么时候该演进 | 用搜索慢、报表拖库、通知失败这些信号触发升级 | > > **重要提醒:下面是教学化案例,不是某个 SaaS 产品的内部图纸。** 数字用于数量级推理,目的是练判断,不是给出唯一答案。 --- ## 开场:为什么普通工单系统也值得写一章 因为大多数团队真正会做的,不是抢票、支付、网约车这种高压系统,而是一个个看起来普通的 SaaS 后台。 > **PatchDesk** 是一个给 20 人以内团队用的轻量工单系统。客户可以提交问题,团队成员可以分派负责人、评论、改状态、发邮件通知、看简单报表。 第一眼看,它很普通: - 租户注册; - 成员管理; - 工单 CRUD; - 评论和附件; - 邮件通知; - 基础搜索和报表。 但普通不等于没有架构。这个系统真正会咬人的地方不是「怎么写一个新增工单接口」,而是: > **一套系统服务很多家公司时,如何让每家公司只看见自己的数据,让不同角色只做自己该做的事,并且别为了将来可能的规模过早背上分布式复杂度。** 这章和上一章 [StarArena](../stararena-ticketing/README.md) 正好相反:上一章是「压力太大,不得不加复杂度」;这一章是「压力还没到,先别乱加复杂度」。 --- ## 读前小词典 这篇会反复出现几个词,先用人话对齐一下: | 词 | 人话解释 | |---|---| | 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 秒内可见 ``` 这个数量级对一个普通关系型数据库和一个模块化单体来说,并不吓人。 真正危险的是另外三件事: 1. **租户隔离**:任何一个查询漏掉 `tenant_id`,就可能让 A 公司看到 B 公司的工单。 2. **权限边界**:普通成员能不能删除工单?外包成员能不能看财务类工单?团队负责人能不能改全局配置? 3. **慢副作用**:发邮件、生成报表、重建搜索索引,如果都卡在用户请求里,体验会变慢,失败还难恢复。 4. **完整历史**:只存 `tickets.status` 这种当前状态不够。谁改了状态、谁重新分派、哪次通知失败,都要能追溯。 所以 PatchDesk 的架构重心不是「怎么抗十万 QPS」,而是: > **把租户、权限、状态历史、慢副作用做成结构,不要靠每个开发者每次都记得。** --- ## 三、触发信号:什么时候说明第一版开始不够用 第一版跑起来后,不要凭感觉升级。看这些信号: | 信号 | 表现 | 为什么这是架构问题 | |---|---|---| | 出现租户串扰风险 | 某个列表接口忘了带 `tenant_id`,测试才发现 | 租户隔离依赖人工纪律,不是结构强制 | | 权限判断散落各处 | 每个接口自己写一段 `if role == ...` | 规则重复且不一致,越权风险会越来越高 | | 工单搜索变慢 | 关键词搜索把主库 CPU 打高 | 搜索读法和事务写法冲突,需要独立索引或读模型 | | 报表拖慢主请求 | 管理员导出月报时,普通工单列表也变慢 | 大查询和在线请求抢同一份数据库资源 | | 邮件通知失败难补 | 创建工单成功,但邮件服务挂了,没人知道通知没发 | 副作用没有 Outbox / 异步任务和投递状态追踪 | | 邮件入口重复投递 | 同一封客户邮件被投递两次,生成两张工单 | 外部输入没有幂等键,重复消息无法识别 | | 审计补不上 | 客户问「谁删了这个工单」,系统答不上来 | 审计日志不是事后开关,要在关键动作里结构化记录 | 这些信号不是在说「要不要上微服务」。它们在说:**边界和副作用开始靠人肉维持了。** --- ## 四、核心矛盾:CRUD 不难,谁能看谁能改才难 PatchDesk 的核心对象只有几个: 1. **租户 / 成员**:哪个公司,哪些人。 2. **工单 / 评论 / 附件 / 工单事件**:客户问题、处理过程和完整时间线。 3. **权限 / 审计 / 通知**:谁能做什么,做了什么,要通知谁。 如果只看增删改查,它很简单: ``` 用户请求 → 查工单 → 改状态 → 写评论 → 发通知 ``` 但真正的系统必须在每一步都回答: - 这个用户属于哪个租户? - 他有没有权限看这张工单? - 这次修改要不要写审计日志? - 通知失败能不能补发? - 搜索 / 报表能不能不拖慢主链路? 所以新的架构命题变成: > **把「租户隔离」「权限判断」「慢副作用」从散落在接口里的代码,收口成系统的固定结构。** 还有一个很容易被低估的点:工单系统不要只保存最终状态。 如果只有这一列: ``` 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 应用](../../templates/standard-web-app/README.md) | 单体应用、关系型数据库、缓存 / 队列按需增加 | 用具体 SaaS 场景说明为什么先单体不是没设计,而是克制 | | [移动 App](../../templates/mobile-app/README.md) | 客户端身份、弱网、推送入口 | 本案例不展开移动端,只把它当 PatchDesk 的一个入口 | | [通知 / 推送系统](../../templates/notification-system/README.md) | 异步通知、重试、投递状态 | 说明发邮件 / 站内消息为什么不能卡在主请求里 | | [安全与多租户](../../tutorial/16-安全与多租户架构.md) | 租户隔离、最小权限、审计 | 把「不能串租户」落成数据访问层和测试的结构约束 | > **读法建议**:先读本章,再回看 [标准 Web 应用模板](../../templates/standard-web-app/README.md)。你会更容易看懂「单体优先」不是偷懒,而是把复杂度预算留给真正会咬人的地方。 --- ## 🎯 随堂检验 --- ## 本案例小结 - **普通 SaaS 不是没有架构,只是不要过度设计。** PatchDesk 第一版最该做的是模块边界、租户隔离、权限收口,不是微服务。 - **它不会先被 QPS 压垮,会先被边界压垮。** 200 个租户的工单量对单体和关系库不吓人;真正危险的是串租户、越权、审计缺失。 - **多租户隔离不能靠人记。** 共享表可以用,但 `tenant_id` 必须由结构强制:请求上下文、数据访问层、自动化测试一起兜住。 - **工单不是只有当前状态,还要有时间线。** `tickets` 存当前态,`ticket_events` 存历史,`outbox_events` 存待投递副作用。 - **慢副作用要挪出主链路。** 发通知、更新搜索索引、生成报表都不该让用户等;异步化不是为了炫技,是为了主链路短、失败可补。 - **先模块化单体,再按信号演进。** 等搜索慢、报表拖库、团队互相阻塞这些信号真的出现,再加读模型、只读副本或拆服务。 > **承上启下**:这一章把 [标准 Web 应用模板](../../templates/standard-web-app/README.md) 里的「克制」落到一个具体 SaaS。下一章如果继续写 AI / RAG 类案例,就会换另一种压力:不是租户和权限,而是答案可信、检索质量、成本和提示注入。 --- ## 相关链接 - 模板对照:[标准 Web 应用](../../templates/standard-web-app/README.md) · [移动 App](../../templates/mobile-app/README.md) · [通知 / 推送系统](../../templates/notification-system/README.md) - 方法论:[02 · 架构师的思考框架](../../tutorial/02-架构师的思考框架.md) · [07 · 从 0 到 1 设计一个系统](../../tutorial/07-从0到1设计一个系统.md) · [08 · 架构决策记录与演进](../../tutorial/08-架构决策记录与演进.md) - 硬骨头:[14 · 演进与拆分大型系统](../../tutorial/14-演进与拆分大型系统.md) · [15 · 组织即架构](../../tutorial/15-组织即架构.md) · [16 · 安全与多租户架构](../../tutorial/16-安全与多租户架构.md)