从单体到多租户:餐饮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,原因很务实:

  1. Element Plus的表格组件——餐饮系统到处都是表格(菜品列表、订单列表、报表),Element Plus的Table组件开箱即用,比Ant Design的Table省事
  2. Vue3的Composition API——比Options API更适合复杂业务逻辑的复用
  3. 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))直接提供前端资源。好处是:

  1. 部署极简——只有一个二进制文件 + 一个Docker镜像,不需要单独的前端容器
  2. 版本强一致——前端和后端永远是同一版本,不存在前端更新了后端没更新的问题
  3. 零配置——不需要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家店的数据如何互不偷看:多租户隔离深度解析

  1. 从单体到多租户:餐饮SaaS架构演进之路
  2. 100家店的数据如何互不偷看:多租户隔离深度解析
  3. 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
  4. 3秒出单200ms响应:餐饮收银系统性能密码
  5. 从1家店到100家店代码一行不用改:多门店架构设计
  6. 退款5年可追溯:餐饮SaaS合规体系构建
  7. 微信扫码→下单→支付→出票,30秒全链路
  8. Go + PostgreSQL + Redis:餐饮SaaS后端架构全景