多租户SaaS系统最难处理的数据库问题,往往不是数据库本身能不能承受多少并发,而是同一套数据库基础设施如何同时保证不同客户的数据隔离、查询性能、事务一致性和资源公平。系统刚上线时,几十个租户共享数据库通常感觉不到问题,等到租户数量、数据量和并发量增长以后,一些早期设计上的小疏忽会逐渐变成跨租户数据泄露、慢查询、连接池耗尽、锁等待甚至整个平台级故障。
多租户数据库首先要解决的不是“用哪一种数据库”,而是租户边界究竟放在哪里。常见方案包括共享数据库共享表、共享数据库独立Schema、每个租户独立数据库,以及混合模式。共享表通常在每张业务表增加organization_id或tenant_id,例如orders表同时保存多个客户的数据。这种方式成本最低、迁移和运维相对简单,但也把数据隔离责任大量交给了应用程序。如果某条SQL忘记加入tenant_id条件,查询结果就可能直接跨越租户边界。更危险的是,这类错误未必马上暴露,因为开发测试环境通常只有一个或几个租户。
因此,多租户系统最值得警惕的并不是“有没有tenant_id字段”,而是整个数据访问链是否始终携带正确的租户上下文。用户登录后确定的租户身份,应该在服务器端建立并验证,不能把浏览器提交的organization_id直接当成可信参数。用户属于哪个组织、拥有哪个角色,应当通过membership关系进行授权判断,然后由服务端生成当前请求的tenant context。订单、客户、发票、文件、日志等租户数据进入SQL查询时,都应该受到这个上下文约束。对于支持Row-Level Security的数据库,还可以进一步利用数据库本身的策略限制跨租户访问,让隔离不完全依赖开发人员“记得写条件”。
这里还有一个很容易被忽略的问题:不仅SELECT需要租户条件,UPDATE和DELETE同样需要。很多严重的数据事故并不是查询时把别人的数据返回给用户,而是后台批量更新、状态修改或者删除操作漏掉了租户条件。一条原本针对某个客户的UPDATE,如果最终只剩下WHERE id = ?,而id又不是全局唯一或调用链中发生了错误,就可能修改错误租户的数据。数据库层的主键、唯一约束、外键以及租户组合约束都应该根据实际模型重新设计,而不能简单复制单租户系统的表结构。
索引是第二个经常被低估的问题。共享表模式下,tenant_id几乎不应该被孤立地看待。假设系统经常执行“某个租户最近的订单”,单独给created_at建立索引未必是最佳选择,更常见的设计是结合查询模式建立类似(tenant_id, created_at)的复合索引。对于“租户+状态+时间”的查询,则可能需要进一步根据选择性和排序需求设计组合索引。索引顺序不能靠固定模板决定,需要根据实际WHERE条件、ORDER BY、数据分布和查询计划判断。
大型SaaS系统还会遇到一个典型问题:租户之间的数据规模严重不均衡。一个拥有几千条订单的小客户和一个拥有几亿条日志的大客户,都使用同一张表时,平均指标可能看起来完全正常,但大租户可能已经占据了大量IO、缓存、CPU和索引访问资源。于是出现“数据库总体CPU只有50%,某些客户却已经明显变慢”的情况。这里的核心概念是noisy neighbor,也就是一个租户的资源消耗影响其他租户。解决方法不一定是马上分库,而可以逐步采用租户级限流、后台任务隔离、冷热数据分离、归档、分区、独立队列,最终再把特别大的租户迁移到独立数据库。
缓存则存在另一种容易导致事故的隔离问题。Redis、应用内存缓存、查询结果缓存、对象缓存都必须把租户上下文纳入缓存键。比如订单缓存不能简单使用order:12345,而应该确保键能够表达正确的租户边界。否则数据库层明明隔离正确,缓存层却可能把A租户的数据返回给B租户。更麻烦的是,缓存污染通常不是每次都发生,而是在特定访问顺序下出现,因此测试阶段很容易漏掉。缓存失效、删除和更新也必须考虑租户维度,否则修改一个租户的数据后,另一个租户的旧缓存仍可能继续存在。
连接池是第三个容易造成“数据库突然死掉”的地方。多租户并不意味着应该给每个租户创建一套独立的数据库连接池。租户数量一旦增长,这种设计很容易产生大量空闲连接和连接管理开销。更合理的方式通常是根据应用实例和数据库拓扑建立有限连接池,再通过请求级租户上下文区分数据。连接池大小也不能简单设置得越大越好。数据库能够承受的并发连接数、CPU核心数量、查询平均耗时、锁等待以及应用实例数量共同决定合理规模。
尤其要注意一个常见的乘法关系:如果应用部署了20个实例,每个实例配置100个数据库连接,那么理论上可能产生2000个数据库连接。数据库看到的是所有应用实例连接的总和,而不是某一台服务器自己的100个连接。连接池配置必须从整个部署拓扑计算,同时设置超时、最大连接数和连接获取等待策略。连接池耗尽时,应用表现出来的可能只是API请求大量超时,但根因实际上发生在数据库连接资源层。
事务设计同样需要按照租户边界重新思考。单租户内部的订单、支付、库存等操作通常可以使用普通数据库事务解决;但如果一个业务动作同时修改两个独立租户,问题就完全不同。很多SaaS业务其实没有必要设计跨租户事务,因为跨租户操作本身就可能意味着错误的业务边界。比如客户转移、平台级结算、跨组织资源共享等场景,应先明确业务模型,再决定采用本地事务、Outbox、消息队列、幂等处理还是分布式事务。不能简单认为引入某个分布式事务框架就能自动解决一致性问题,因为网络故障、锁等待、超时、重试和参与者状态都会把问题带入更复杂的状态机。
数据一致性还经常出现在并发更新上。典型场景是两个请求同时读取同一条记录,然后分别计算新值并写回数据库。如果没有版本控制,其中一个更新可能覆盖另一个更新。乐观锁通常可以通过version字段实现,例如UPDATE时同时要求WHERE id = ? AND version = ?,然后检查受影响行数。如果返回0,就说明当前版本已经发生变化,需要重新读取或者向上层报告冲突。这里检查UPDATE返回值非常重要,因为“SQL执行成功”与“业务更新成功”不是同一回事。
锁竞争则是另一个容易被错误归因的问题。一个多租户系统出现大量数据库锁等待,并不意味着应该直接降低事务隔离级别。首先应该查看锁等待对象、持锁事务、事务持续时间、SQL执行计划以及索引情况。一个缺少索引的UPDATE可能扫描大量记录并扩大锁影响范围;一个事务中夹杂外部API调用,则可能长时间持有数据库事务;一个批处理任务如果没有控制批量大小,也可能与在线请求形成严重竞争。优化锁问题通常首先是缩短事务、减少锁范围、改善索引和调整业务并发模型,而不是简单把隔离级别从RR改成RC。
分页也是多租户系统进入大数据量阶段后的常见陷阱。早期使用LIMIT 50 OFFSET 10000通常没有问题,但当某个租户拥有数百万甚至数千万条记录以后,大OFFSET分页可能越来越昂贵。对于按照时间或递增ID排列的数据,可以考虑基于游标或seek方式分页,例如使用WHERE id < ? ORDER BY id DESC LIMIT 50,同时结合tenant_id建立合适索引。这样数据库不需要为了找到最后50条记录而跳过大量历史数据。
后台任务同样必须带着租户上下文运行。很多系统在线API已经做好了tenant_id隔离,但到了定时任务、消息队列、异步Worker、报表生成、文件处理和数据导出阶段,租户信息却没有继续传递。于是线上最危险的跨租户问题反而可能出现在“不属于任何用户直接操作”的后台程序里。队列消息、缓存键、对象存储路径、搜索索引、日志和监控标签都应该明确记录租户维度。数据库隔离做得再严密,如果文件导出程序使用了错误的租户路径,同样可能产生数据泄露。
Schema隔离也不能简单理解成“每个租户一个Schema,所以一定安全”。它确实可以降低共享表中的部分隔离风险,但当租户数量达到很大规模时,Schema数量、迁移、索引、连接管理、备份和监控都会增加复杂度。每次数据库结构升级都要考虑所有Schema是否完成迁移。独立数据库则进一步提高隔离能力,但运维、备份、连接、监控、版本升级和成本都会增加。所谓Shared-Nothing也不是一种可以直接替代所有方案的数据库设计答案,而更接近一种资源隔离和系统架构思想。
实际工程中,越来越常见的是混合架构。普通租户使用共享数据库和共享表,大型客户或者对隔离、合规、数据驻留有特殊要求的客户迁移到独立数据库。这样可以把数据库成本与客户规模联系起来,同时避免一开始就为所有租户维护大量独立数据库。架构设计时甚至可以预留tenant_id到database mapping,使系统未来能够把某个租户从共享库迁移到专用库,而不需要重写整个业务系统。
多租户数据库还需要建立租户级监控,而不是只看数据库总CPU和总连接数。一个成熟系统至少应该能够回答:哪个租户产生了最多查询,哪个租户占用了最多IO,哪个租户出现最多慢查询,哪个租户产生最多锁等待,哪个租户使用最多存储,哪个后台任务长期占用资源,以及某个租户迁移到独立数据库后系统资源是否得到改善。没有这些维度,所谓数据库监控往往只能告诉开发团队“数据库很忙”,却无法回答“是谁让数据库变忙”。
多租户数据库设计的核心原则其实很明确:User不是Tenant,Tenant不是Membership,Membership也不是Role,而业务数据必须明确属于哪个Tenant。权限控制负责决定用户能不能访问,租户上下文负责决定数据属于谁,数据库约束负责建立最后一道边界,索引负责保证隔离条件不会让查询性能失控,连接池和资源控制负责避免一个租户拖垮其他租户,监控则负责让这些问题在变成事故之前暴露出来。
SaaS系统真正进入规模阶段以后,数据库架构的难点已经不再是“能不能把所有客户放进一张表”,而是能不能在共享资源的同时,把数据、权限、缓存、连接、锁、任务和性能边界都定义清楚。一个优秀的多租户设计并不追求所有租户永远使用同一种数据库结构,而是让系统能够随着租户规模和业务重要性的变化,在共享与隔离之间平稳迁移。
多租户系统最容易出现哪些数据库问题,开发时这些地方需要特别小心
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
分享 Facebook | X | WhatsApp | LinkedIn
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP