从单体到多租户:餐饮SaaS架构演进之路
从单体到多租户:餐饮SaaS架构演进之路
做餐饮系统这些年,我见过太多"第一版能跑就行"的项目。龙讯餐饮V1就是这样一个典型——单体架构、一把梭写到底,一家店用着还行,十家店就开始出问题,一百家店?直接原地爆炸。
V2从零开始重构,我们选了Go + PostgreSQL + Redis + Vue3的技术栈,做了真正的多租户SaaS架构。这篇文章,我想把这段演进之路完整地讲给你听。
V1:一个典型的单体悲剧
V1的技术栈说出来都是泪:Go + chi + SQLite/MySQL + 一个Nginx。当时的需求很简单——给一家火锅店做点菜收银系统,所以架构也简单:
浏览器 → Nginx → Go API (chi) → SQLite/MySQL
一家店用的时候,这架构没任何问题。但当客户从1家变成10家、50家的时候,噩梦开始了:
| 问题 | 具体表现 |
|---|---|
| 数据隔离靠代码 | 每个查询都要手动加 WHERE shop_id = ?,漏加就是数据泄露 |
| 部署成本线性增长 | 每个商户一套环境,10个商户10台服务器 |
| 代码复用率低 | 每个商户的定制逻辑散落在各处,升级时牵一发动全身 |
| 性能瓶颈明显 | 单库单表,高峰期订单表锁等待严重 |
最惨的一次,某个商户的店长登录后看到了另一个商户的营业数据。虽然只是展示层面的Bug,但客户直接打电话过来:“你们这系统,我的商业机密还安全吗?”
那一刻我就知道:V1的架构已经走到了尽头。
多租户:不是选择题,是必答题
多租户(Multi-Tenancy)是SaaS的核心能力。简单说,一套系统服务多个商户,但每个商户的数据互相隔离,就像一栋楼里的不同公司——共用电梯和物业,但各自办公室的门禁卡互不通用。
多租户有三种经典模式:
| 模式 | 隔离级别 | 成本 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 最高 | 最高 | 最高 | 金融、医疗等强合规 |
| 共享数据库,独立Schema | 中等 | 中等 | 中等 | 中型SaaS |
| 共享数据库,共享Schema | 最低 | 最低 | 最低 | 大规模SaaS |
我们选了第三种——共享数据库、共享Schema,但用PostgreSQL的**行级安全策略(Row Level Security,RLS)**来做数据隔离。
为什么?因为餐饮SaaS的商户数量可能上千,独立数据库或独立Schema的运维成本我们扛不住。而RLS能在数据库层面强制隔离,即使应用层代码有Bug,也不会出现跨商户数据泄露。
这个决策,是整个V2架构的基石。
技术栈选型:为什么是Go?
选技术栈的时候,团队里吵得不可开交。三个方案摆上桌面:
方案A:Java (Spring Boot)
优势:
- 生态成熟,MyBatis-Plus、Spring Security开箱即用
- 团队大部分人写过Java
- 招聘容易
劣势:
- JVM内存占用大,10个商户的微服务就吃掉8GB内存
- 启动慢,冷启动动辄30秒
- 餐饮收银场景对延迟敏感,GC停顿不可接受
方案B:Node.js (NestJS)
优势:
- 前后端语言统一(TypeScript)
- 异步I/O天然适合高并发
- 开发效率高
劣势:
- 单线程模型,CPU密集型任务(报表生成、加密运算)会阻塞事件循环
- 数字精度问题——JavaScript的Number最大安全整数是2^53-1,金额计算有风险
- 大型项目的类型安全不如Go和Java
方案C:Go (chi + GORM)
优势:
- 编译型语言,无GC停顿之忧(Go的GC延迟通常在亚毫秒级)
- 内存占用极低,一个服务50MB就够了
- 天然并发,goroutine比线程轻量1000倍
- 静态类型 + 编译检查,金额计算不会出精度问题
- 单二进制部署,Docker镜像才20MB
劣势:
- 团队需要学习成本
- GORM不如MyBatis-Plus那么"智能"
- 错误处理比较啰嗦(
if err != nil)
最终我们选了Go。核心原因就一个:餐饮收银系统,200ms响应是底线,3秒出单是目标。Java的GC和Node.js的单线程都可能在高峰期翻车,而Go的轻量级并发模型和低内存占用,正好匹配我们的场景。
至于团队学习成本?说实话,Go的上手曲线比Java还平缓。一个有经验的开发者,一周就能写生产代码。
PostgreSQL:不只是"另一个数据库"
选PostgreSQL而不是MySQL,这个决定让运维同事纠结了很久。但最终PostgreSQL赢了,原因有三个:
1. 行级安全策略(RLS)
这是最核心的原因。PostgreSQL的RLS允许我们在数据库层面强制执行行级访问控制:
-- 启用RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 创建策略:只能看到自己租户的数据
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::BIGINT);
这就像给每层楼装了门禁——即使你进了大楼(连上了数据库),没有对应楼层的门禁卡(tenant_id),也进不了别人的办公室(看不到别人的数据)。
MySQL没有原生的RLS支持,要做行级隔离只能靠视图或应用层过滤,前者维护成本高,后者不可靠。
2. JSONB支持
餐饮行业的数据结构变化快——今天加个口味选项,明天加个套餐规则。PostgreSQL的JSONB类型让我们可以在关系型数据库里灵活存储半结构化数据:
type Dish struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
Name string
ExtraInfo datatypes.JSON `gorm:"type:jsonb"` // 灵活扩展字段
}
3. 更好的并发控制
PostgreSQL的MVCC(多版本并发控制)实现比MySQL的InnoDB更彻底。在餐饮高峰期,大量并发写订单时,PostgreSQL的锁冲突率明显更低。
Redis:不只是缓存
Redis在V2里扮演了四个角色:
| 角色 | 用途 | 为什么不用其他方案 |
|---|---|---|
| Token黑名单 | JWT注销后立即失效 | 数据库查询太慢,本地缓存多实例不共享 |
| 限流器 | API调用频率限制 | 令牌桶算法需要原子操作 |
| 分布式锁 | 防止订单重复提交 | 数据库锁太重,影响并发 |
| 幂等键 | 支付请求去重 | 支付重试必须保证幂等 |
这些场景有一个共同特点:需要原子操作 + 低延迟。Redis的SET NX EX一条命令就能搞定分布式锁,用数据库实现至少要三条SQL加一个事务。
Vue3 + Element Plus:前端的选择
前端选Vue3而不是React,原因很务实:
- Element Plus的表格组件——餐饮系统到处都是表格(菜品列表、订单列表、报表),Element Plus的Table组件开箱即用,比Ant Design的Table省事
- Vue3的Composition API——比Options API更适合复杂业务逻辑的复用
- ECharts集成——营业报表、趋势分析,ECharts的Vue3封装比React版更稳定
// 一个典型的组合式函数
function useOrderList(tenantId: Ref<number>) {
const orders = ref<Order[]>([])
const loading = ref(false)
async function fetchOrders() {
loading.value = true
const { data } = await api.getOrders(tenantId.value)
orders.value = data
loading.value = false
}
return { orders, loading, fetchOrders }
}
从V1到V2:架构对比
| 维度 | V1 | V2 |
|---|---|---|
| 语言 | Go (chi) | Go 1.22 |
| 数据库 | SQLite/MySQL | PostgreSQL 16 |
| 缓存 | Redis | Redis 7 |
| 前端 | 内嵌SPA (go:embed) | Vue3 + Element Plus (go:embed) |
| 租户隔离 | 代码层WHERE | PostgreSQL RLS |
| 部署 | 单机 Docker Compose + go:embed | 单机 Docker Compose + go:embed |
| 认证 | JWT RS256 | JWT RS256 + RBAC |
| 金额精度 | int64(分) | int64(分) |
| 加密 | 无 | AES-256-GCM |
每一个变化背后都有血泪教训。比如金额精度——V1虽然也用int64存分,但缺少PostgreSQL RLS的强制隔离,手动加WHERE tenant_id = ?总有遗漏的时候。V2用RLS彻底杜绝了跨租户数据泄露的风险。
Go里的shopspring/decimal包在需要做除法、税率计算时提供了额外保障:
price := decimal.NewFromInt(2990) // 29.90元,以分为单位存储
taxRate := decimal.NewFromFloat(0.06)
tax := price.Mul(taxRate).Round(0) // 精确计算税额
部署架构:Docker Compose + go:embed
很多人一上来就Kubernetes,但对我们的规模来说,Docker Compose才是最务实的选择:
V2的一个重要架构特征是前端SPA编译进Go二进制。通过 //go:embed dist 指令,Vue3编译产物直接嵌入Go可执行文件:
package web
import "embed"
//go:embed dist
var DistFS embed.FS
这意味着前端不是独立部署的Nginx静态服务,而是嵌入在Go服务中,通过http.FileServer(http.FS(web.DistFS))直接提供前端资源。好处是:
- 部署极简——只有一个二进制文件 + 一个Docker镜像,不需要单独的前端容器
- 版本强一致——前端和后端永远是同一版本,不存在前端更新了后端没更新的问题
- 零配置——不需要Nginx做前端路由,Go服务直接处理SPA的fallback
整体部署架构:
Nginx (TLS终止)
├── go-api-1 (2实例,内嵌前端SPA)
├── go-api-2 (2实例,内嵌前端SPA)
├── PostgreSQL 16
└── Redis 7
Nginx做TLS终止和负载均衡,两个Go实例保证高可用,PostgreSQL和Redis各一个实例。整个部署一个docker-compose up -d搞定,运维成本极低。
等商户数量超过500家,我们再考虑Kubernetes迁移。但现在?Docker Compose完全够用。
回顾与展望
从V1到V2的演进,核心思路就一句话:用正确的工具做正确的事。
- Go解决的是性能和并发问题
- PostgreSQL RLS解决的是数据隔离问题
- Redis解决的是原子性和低延迟问题
- Vue3解决的是开发效率和用户体验问题
- Docker Compose解决的是部署和运维问题
这些技术选型不是追新,而是每一个都对应着V1里踩过的坑。
下一篇,我们会深入多租户隔离的实现细节——JWT的tenant_id怎么传递、PostgreSQL RLS怎么配置、GORM的TenantScope怎么封装、缓存key怎么加租户前缀。这些细节做好了,才能真正做到"100家店的数据互不偷看"。
系列导航:[龙讯餐饮SaaS架构实战]
← 上一篇:无 | 下一篇 →100家店的数据如何互不偷看:多租户隔离深度解析
- 从单体到多租户:餐饮SaaS架构演进之路
- 100家店的数据如何互不偷看:多租户隔离深度解析
- 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 3秒出单200ms响应:餐饮收银系统性能密码
- 从1家店到100家店代码一行不用改:多门店架构设计
- 退款5年可追溯:餐饮SaaS合规体系构建
- 微信扫码→下单→支付→出票,30秒全链路
- Go + PostgreSQL + Redis:餐饮SaaS后端架构全景