做SaaS系统绕不开多租户这个话题。同一个系统服务成百上千个客户,谁的订单谁的客户资料必须分得清清楚楚,一旦串了数据就是严重事故。提起多租户大家首先想到MySQL、PostgreSQL这些网络数据库,其实在中小规模场景下,SQLite完全能扛住这个担子,而且它免部署、零运维的特性反而让多租户架构更简单。本文结合实际项目经验,聊聊用SQLite做多租户隔离的几种思路和落地代码。

方案一:共享表加tenant_id字段
这是最直接的做法。所有租户共用一套表,每张业务表都加一个tenant_id字段,查询时强制带上这个条件。它的最大优势是结构简单,一张表就能看到全部数据,统计报表、后台管理都好做,而且租户数量增长时不需要任何结构性改动。
建表时把tenant_id作为复合主键的一部分,可以有效利用索引加速过滤:
-- 订单表,tenant_id参与复合主键
CREATE TABLE orders (
tenant_id INTEGER NOT NULL,
order_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
amount REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (tenant_id, order_id)
);
-- 按租户查订单,索引直接命中
CREATE INDEX idx_orders_tenant_user ON orders(tenant_id, user_id);
麻烦的地方在于隔离靠应用层代码保证。只要有一处查询忘记加tenant_id条件,数据就泄漏了。一个实用的兜底手段是利用SQLite的临时表或者查询视图,在建立连接的瞬间把当前租户上下文写进会话,业务SQL统一走视图:
-- 每个租户一个连接时,先写入会话变量 CREATE TEMP TABLE session_ctx (tenant_id INTEGER); INSERT INTO session_ctx VALUES (42); -- 业务层只查视图,物理表不直接暴露 CREATE VIEW v_orders AS SELECT * FROM orders WHERE tenant_id = (SELECT tenant_id FROM session_ctx);
这套机制等于在数据库层加了一道保险,应用代码想绕都绕不过去。代价是每个租户请求要独占一个连接,连接管理代码要写得严谨些。
方案二:一个租户一个数据库文件
物理隔离是干净利落的方案。每个租户一个独立的.db文件,互不干扰,单个租户的备份、迁移、删除都是文件级操作,复制粘贴就完事。遇到闹纠纷要销毁某个租户数据的客户,直接删文件,法律合规层面非常省心。数据彻底隔离,连SQL写错都不可能查到别的租户。
实现的关键是连接管理器,根据请求携带的租户标识打开对应的数据库文件,并做好连接缓存:
package tenantdb
import (
"database/sql"
"fmt"
"sync"
_ "github.com/mattn/go-sqlite3"
)
type Manager struct {
mu sync.RWMutex
conns map[string]*sql.DB
dir string
}
func NewManager(dir string) *Manager {
return &Manager{conns: make(map[string]*sql.DB), dir: dir}
}
// GetDB 根据租户ID返回对应的数据库连接
func (m *Manager) GetDB(tenantID string) (*sql.DB, error) {
m.mu.RLock()
db, ok := m.conns[tenantID]
m.mu.RUnlock()
if ok {
return db, nil
}
m.mu.Lock()
defer m.mu.Unlock()
// 双重检查,防止并发重复建连
if db, ok := m.conns[tenantID]; ok {
return db, nil
}
dsn := fmt.Sprintf("file:%s/%s.db?cache=shared", m.dir, tenantID)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
// 打开WAL模式,提升并发读写能力
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
return nil, err
}
m.conns[tenantID] = db
return db, nil
}
注意上面代码里的两个细节。一是使用了cache=shared参数,让同一进程内对同一租户的多个连接共享数据缓存,减少重复加载页面的开销;二是开启了WAL模式,这是SQLite多租户部署里几乎必开的选项,后面单独展开讲。
这种方案的短板也很明显:租户一多,文件数量膨胀,全平台级别的跨租户统计查询要遍历所有库,写起来很痛苦。所以它适合租户数量在几百到几千之间、且报表需求主要集中在单租户维度的场景。
方案三:ATTACH挂载与WAL并发优化
有没有办法既保留物理隔离,又能跨库查询?SQLite的ATTACH DATABASE语句可以做到。把多个租户库挂到同一个连接上,用db名字.表名的语法访问:
ATTACH DATABASE 'tenants/a.db' AS tenant_a; ATTACH DATABASE 'tenants/b.db' AS tenant_b; -- 跨租户统计订单总量 SELECT 'a' AS tenant, COUNT(*) FROM tenant_a.orders UNION ALL SELECT 'b', COUNT(*) FROM tenant_b.orders;
要注意ATTACH默认上限是10个左右(编译期参数SQLITE_MAX_ATTACHED决定),动态租户多的时候不能一股脑全挂上,通常按需挂载、用完立刻DETACH。如果租户是按编号划分的,比如分片后每个文件承载几十个租户,ATTACH就非常实用。
再说说WAL。SQLite默认的回滚日志模式下,写入会锁住整个数据库,读也得等。开启WAL后写入发生在独立的-wal文件里,读写可以并行,单机并发能力能提升一个量级。多租户场景建议同时配置这几个PRAGMA:
PRAGMA journal_mode=WAL; -- 写前日志,读写不互斥 PRAGMA synchronous=NORMAL; -- 平衡安全与性能 PRAGMA busy_timeout=5000; -- 写锁冲突时等待5秒再报错 PRAGMA wal_autocheckpoint=1000; -- 每1000页自动合并wal文件
busy_timeout尤其重要。多租户系统里写请求来自不同租户,如果两个连接同时写同一个库,没有超时设置会直接抛database is locked错误,加上5000毫秒的等待后,绝大多数冲突会自动排队化解,用户完全无感。
选型建议与踩坑提醒
三种方案怎么选,可以按租户规模和业务特征来定。租户量大、单租户数据量小、需要全局报表的,选共享表加tenant_id;租户数量适中、对隔离性要求高、合规要求严格的,选独立文件方案;想两者兼顾的,用分片加ATTACH的混合模式。实际项目里也有按客户付费等级分层的做法:免费用户挤在共享库里,付费大客户单独文件,扩容时只迁移头部客户,改动面很小。
几个容易踩的坑这里列一下。第一,千万不要把SQLite数据库文件放在网络文件系统(NFS、SMB)上,文件锁在网络盘上不可靠,容易出现数据损坏,多租户库务必放在本地SSD。第二,做租户级备份时先用VACUUM INTO 'backup.db'或者.backup命令生成快照,不要直接复制正在写入的.db文件,那样拿到的很可能是损坏的副本。第三,如果用独立文件方案,记得给每个库文件名做白名单校验,租户ID只能是数字或UUID格式,防止路径拼接被注入成任意文件路径,这属于多租户系统特有的安全面。
最后总结一句:SQLite做多租户不是玩具方案,配上WAL和合理的隔离设计,单机支撑几百个活跃租户毫无压力。等流量真涨到需要分布式的时候,这套按租户拆分的结构迁移到MySQL分库分表也非常顺滑,前期投资不会白费。
SQLite多租户数据库隔离SharedWorker修改时间:2026-09-13 00:46:40