ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

多租户架构实战:从独立数据库到共享Schema的演进与隔离方案

多租户架构实战:从独立数据库到共享Schema的演进与隔离方案 多租户架构是几乎所有 SaaS 和平台类系统都要面对的一道选择题同一个系统要服务多个客户每个客户的数据、配置、资源使用边界到底怎么画。尤其是在“千万 QPS 架构”这个量级下独占和共享不再是简单的“省不省钱”问题而是直接决定系统能否在一个合理的成本范围内稳定扩容。这篇笔记写的是我从独立数据库独占部署到共享 Schema 的演进过程里比较容易踩坑的地方以及最终沉淀下来的一套判断标准。适合正在设计多租户后台、准备改造老系统或者已经上了共享方案但担心隔离不够的读者。我一直强调一个观点多租户架构没有绝对的最优解只有当前业务阶段下的最合适解。所以下面不会给你一个“照着抄就行”的模板而是把独占和共享两条路线各自的条件、代价和适用边界拆清楚再给出一套可以实际落地、可以验证、可以回滚的操作链路。1. 先分清租户隔离的三种主流做法很多人一上来就问“多租户到底选哪种”其实这个问题应该反过来问你的租户是谁每个租户有多少数据单个租户能不能承受噪声干扰。租户隔离通常有三种做法它们的命名在各个团队里略有差异但本质是一样的。1.1 每租户独立数据库隔离性最好成本最高第一种是数据库级隔离也就是一个租户一个数据库甚至一个租户一组数据库实例。这种模式的好处非常明显数据隔离最彻底一个租户的慢查询、大批量导入、异常事务几乎不会影响到其他租户。备份恢复也简单直接按库操作就行不会误碰别人的数据。权限控制也清晰数据库账号层面就可以把租户隔开。它的代价同样明显主要花在三个地方资源浪费。每个租户都要预留一定连接数、磁盘空间和备份窗口。小租户可能一个月才几十万条数据但也要占一个库的最小资源。运维成本高。Schema 变更要逐个库执行索引调整要逐个库验证监控告警要按库维度汇总。连接数压力大。应用侧每接一个租户就要多一批连接数据库连接数很容易被占满尤其在租户数量从几十涨到几百的时候。这种模式适合企业级大客户、金融合规场景、数据敏感度极高的业务。如果租户数量少、客单价高、平均数据量大它是合理的。但如果你的目标是千万 QPS 的公共平台纯托管大量小租户数据库级隔离会先撑爆你的运维精力而不是先撑爆计算资源。1.2 共享数据库、独立 Schema折中方案第二种是共享数据库、独立 Schema也就是所有租户在同一个数据库实例里但每个租户有自己的 Schema 或表前缀。它的数据隔离比共享表好一些一个租户的表结构调整不会直接影响其他人查询也基本限定在自己的表里。相比数据库级隔离连接数压力小很多因为所有租户可以共用一组连接只是在查询时通过 schema 定位。但这种模式在落地时会遇到不少麻烦Schema 数量膨胀。租户到一万个就会有上万套表结构元数据管理、备份、迁移都会变得很重。连接和 schema 切换复杂。很多 ORM 框架对动态 schema 的支持并不友好连接池要处理不同 schema 的上下文切换。单库容量依然有限。虽然不用每个租户一个库但所有 Schema 都在同一个实例上实例的 CPU、内存、磁盘上限还是共同的天花板。这个方案适合数据模型差异很大的租户比如每个租户有自定义字段、自定义流程。它比独立数据库省成本但比共享表更复杂。如果租户的数据结构高度一致这个方案反而属于自找麻烦。1.3 共享 Schema 加租户 ID高 QPS 平台的常态第三种是共享数据库、共享 Schema所有租户的数据存在同一套表结构里通过tenant_id这样的字段区分归属。这是目前大多数互联网高并发平台的多租户常态。核心原因很简单数据模型统一一套代码、一套表、一套缓存扩容时加实例就行成本弹性最好。千万 QPS 这个级别如果没有强烈合规要求几乎都会走向这里。但它的问题也是最容易被低估的主要是三件事所有查询都必须带租户条件。任何一个地方漏了tenant_id轻则数据串掉重则把全量数据扫出来。索引设计必须把租户维度放进去。(tenant_id, 其他业务字段)是最常见的组合单查业务字段不查租户性能很容易崩。一个租户出问题会波及所有人。大租户的慢查询、热 Key、超大批量写入会占用共享资源。所以共享 Schema 不是“简单地把表合并”而是要把租户维度从数据模型、缓存设计、限流策略到监控告警全部串起来。后面几个部分我会重点拆这条路线。2. 从独占走向共享到底在权衡什么很多团队在租户量小的时候天然选择独立数据库因为开发最快、隔离最省心。等到租户量上来成本开始失控才想起来要往共享迁移。这个迁移过程里最重要的不是表结构怎么合并而是先想清楚你在用什么东西换什么东西。2.1 隔离性、成本、吞吐量三者不可能同时拉满多租户架构本质上是在三个维度上做取舍维度独立数据库独立 Schema共享 Schema数据隔离性最高中高低靠字段和代码保证连接资源消耗高中低运维复杂度高中低但代码要求高单租户定制能力强中弱扩容方式按租户扩库按实例扩按实例和分片扩适用场景大客户、合规数据模型差异大统一模型、海量小租户这个表格不是让你选“最好”的那列而是让你根据业务付费模式来选。如果你的定价方式是每个大客户单独收费独立数据库贵一点也合理如果是按用量付费的海量小租户共享 Schema 是唯一能把毛利做正的选择。2.2 高 QPS 场景下为什么共享是必然选择千万 QPS 意味着什么先说几个数字感受一下。假设单机应用能稳定扛 2000 QPS你需要 5000 台应用实例才能扛住总量。数据库侧即使靠缓存扛掉大部分读压力写入和最终查询也至少需要几十个数据库实例。这时候如果每个租户一个数据库光连接数就已经是一笔巨大的开销。再算一笔账一个数据库实例的连接上限通常在几百到几千如果 1000 个租户每个都占用 20 条连接那就是 2 万条连接。为了维护这些连接应用和数据库两头都要付出大量内存和 CPU。这种开销换来的只是“表面上的隔离”实际上大多数租户根本不会同时打满自己的连接。所以高 QPS 平台的现实选择是数据库实例数量跟总流量走而不是跟租户数量走。共享 Schema 恰恰能让实例数跟流量挂钩。流量涨了加实例租户涨了只要总流量没涨实例数可以基本不动。2.3 共享后真正的难点不是表结构而是资源边界我见过不少团队把多张表加上tenant_id就宣布完成了多租户改造。上线没几天就出问题问题往往不是 SQL 写错而是资源边界没画清楚。共享模型里一个租户的“独占感”只能通过以下手段模拟出来应用层的租户上下文传递数据库层的索引和查询条件强制约束缓存层的租户维度 Key 隔离中间件层的限流、配额、优先级队列监控层的租户维度指标采集这些手段缺一个共享方案就很容易演变成“一个超级大租户拖死所有人”。所以从独占走向共享真正的难点在于把原先靠物理隔离解决的事情改成靠代码、规范、中间件和监控来解决。3. 落地共享多租户系统的核心链路下面按我实际改过的项目顺序拆一遍。不管你是新系统还是老系统改造这套链路基本适合。核心思路永远是先跑通最小的单租户链路再开放批量租户最后再谈优化。3.1 租户从请求头到数据库连接的完整路由第一步不是改表而是先解决“这个请求属于哪个租户”。常见做法是在网关或登录态里取出租户标识写入请求上下文然后在业务代码里通过上下文拿到租户 ID执行查询时自动带上。以 Java 为例通常用ThreadLocal或TransmittableThreadLocal传递租户信息。在一个拦截器里解析public class TenantInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId request.getHeader(X-Tenant-Id); if (tenantId ! null) { TenantContext.set(tenantId); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContext.clear(); } }这里要特别注意两点一是异步任务、MQ 消费、定时任务里没有 HTTP 请求头必须手动设置租户上下文二是ThreadLocal在线程复用时会串数据用完务必清理。如果使用 MyBatis可以通过拦截器给 SQL 自动追加租户条件。但我不建议一上来就做全自动拦截因为复杂 SQL、子查询、JOIN 表里有没有租户字段机器判断不一定准确。先手动在 SQL 里写清楚跑通以后再考虑统一拦截。3.2 数据访问层必须守住三条底线共享 Schema 的数据访问层我认为有三条底线任何一条都不能破第一所有的写操作必须显式设置租户 ID不依赖数据库默认值。因为一旦依赖默认值某一个写入链路忘了填充脏数据直接就进去了而且很难追溯。第二所有查询必须带租户条件尤其是聚合查询、报表查询和后台管理查询。后台管理经常容易漏因为它看起来像“内部系统”但内部系统也要分租户。第三租户 ID 必须走索引。如果一张表的查询全是全表扫业务量小的时候没感觉租户一多立刻崩。从实践上看很多数据串租户的事故不是恶意攻击而是开发在写update或delete时漏了租户条件。对这种风险我的建议是核心表的更新和删除在 DAO 层做一次租户条件自动校验发现没有租户条件直接报错宁可先拦住也不要生产事故。3.3 连接池、事务和租户上下文怎么配合共享 Schema 下连接池是所有租户共用的事务边界也需要考虑租户上下文是否一直有效。实操中我会这样做连接池按业务模块拆分比如订单库、用户库分开而不是一个大池子扛所有流量。每个连接池设置最大连接数、最小空闲连接数、连接最大空闲时间避免某个模块突发流量把连接池打满。事务方法里避免跨租户调用。如果一个事务同时操作多个租户的数据要么确认业务上真的需要要么拆成多个事务。事务开始时先确认租户上下文已设置。如果进入事务方法发现租户 ID 为空立刻抛出异常防止把别人的数据写进去。一个常见的坑是通过 MQ 异步消费时消费者线程从线程池里取到一条之前残留了租户上下文的线程结果消费逻辑把上一条消息的租户当成当前租户。解决方法是在消费入口处强制性覆盖租户上下文并在消息体里显式携带租户 ID。4. 千万 QPS 场景下的性能与稳定性设计当架构目标到千万 QPS多租户就不再只是数据隔离问题而是性能和稳定性的综合问题。这里挑几个最容易被忽视的点展开。4.1 缓存 Key 必须带租户维度共享缓存里最怕的就是缓存 Key 没有租户维度。比如一个商品信息的缓存 Key 是product:1001租户 A 和租户 B 都有 id 为 1001 的商品结果必然串数据。正确的缓存 Key 设计应该是tenant:{tenantId}:product:{productId}如果是 Redis还要考虑热 Key 问题。热门租户的某些 Key 访问量会特别大比如大客户首页推荐商品。这时候按租户维度随机加后缀分散读压力是常见的优化手段。另外缓存失效策略也要考虑租户维度。有的租户希望数据实时更新有的租户可以接受几分钟延迟。如果全局统一一个过期时间要么牺牲大租户的实时性要么给小租户浪费缓存资源。我的建议是把缓存策略做成租户可配置的并且默认值先从保守配置开始。4.2 限流和配额要按租户维度做共享模型下如果不限制单个租户的吞吐一个大租户的突发流量可能占满整个集群。这里说的不只是接口 QPS还包括消息队列消费速率、批量任务并发数、文件上传带宽。常见的做法是分层限流全局限流保护整个集群不被打垮。租户级限流每个租户在单位时间内的请求上限。接口级配额不同接口对同一租户设置不同限制。比如tenant-limits: default: qps: 200 maxConcurrentTasks: 5 enterprise_whale: qps: 2000 maxConcurrentTasks: 20这里的关键不是把配额写死而是要有动态调整能力。大租户可能在做活动需要临时调高配额小租户如果被刷量需要快速压到很低的阈值。另外限流算法建议用令牌桶或滑动窗口而不是简单的计数器。计数器的边界问题在大流量下会造成瞬间通过量远超预期。4.3 线程池隔离与多租户任务队列很多系统不只有接口请求还有异步任务、消息消费、批量导出、定时报表。这类任务在共享 Schema 场景下同样要做租户维度隔离。我见过一个案例某平台后台有一个定时任务每天凌晨批量给所有租户生成报表。以前是租户一个接一个顺序跑某个租户数据量大后面所有租户的报表都被延后。后来改成按租户拆分任务队列每个租户的任务独立提交某一个大租户跑得慢也不会阻塞其他租户。具体实现思路把任务按租户分组每个租户或每组租户分配一个线程池。线程池的队列容量、最大线程数、拒绝策略要单独配置。大租户的任务单独排队并且可以设置超时熔断超时就跳过并告警。如果不想为每个租户都开一个线程池也可以用一个共享线程池但每个租户的任务数量要受限避免某个租户占满队列。这里优先保证的是“不让一个租户饿死其他租户”。4.4 监控指标要看租户维度而不只是看全局千万 QPS 的全局指标很好看但真正的隐患往往藏在某一类租户里。监控系统至少要能按租户维度看以下指标指标判断标准请求 QPS单租户是否接近配额上限P99 延迟是否有租户出现明显劣化数据库慢查询慢查询集中在哪些租户连接池活跃数是否被个别租户占满缓存命中率热点是否集中在某些租户任务积压数是否有个别租户任务堆积这些指标不是要全部展现在大盘上而是要有按租户下钻的能力。出现告警时先定位租户再定位接口和 SQL能省大量排查时间。5. 从独占到共享的迁移路径老系统改造最忌讳的就是“一次性切过去”。正确做法是把迁移当成一个可以分阶段回滚的项目来做。5.1 先小租户试点再逐步合并我建议的迁移顺序是先准备新共享库建立共享表结构。挑几个数据量小、允许短时不可用的租户做试点。试点通过后再迁中等规模的租户。最后才处理大租户和极高 QPS 租户。为什么从小租户开始因为小租户数据量小迁移失败的影响范围小回滚也快。你把大租户放在最后等流程、代码、工具都稳定了再碰它风险就低很多。5.2 数据迁移的常用顺序从独立库迁往共享库一般按这个顺序全量数据复制先保证新库有完整历史数据。增量数据同步用 binlog 或类似的日志同步机制保持新旧两库接近实时一致。数据校验按租户维度对比行数、关键字段、最新更新时间不一致的先处理不要急着切换。应用切换读流量切到新库观察错误率和耗时。切换写流量同时保留旧库写入作为兜底。关停旧库写入保留只读一段时间方便回滚。整个过程中数据校验是重头。不要只看行数一致还要抽样对比关键字段尤其是金额、状态、时间这类敏感字段。5.3 灰度切换和回滚方案切换时建议按租户灰度而不是按接口灰度。一个租户的流量先切到新架构跑一段时间没问题再切下一批。切流前要把回滚方案写清楚代码层面保留开关可以随时把租户路由切回旧库。数据层面旧库数据至少保留 7 到 30 天不做物理删除。配置层面所有切换动作通过配置中心控制不要改代码发版。我见过最惨烈的案例是迁移后旧库被直接删了结果新库某个统计口径对不上想回滚已经没机会。所以迁移项目里数据保留时间不是可选项而是必选项。6. 常见问题与排查链路多租户系统上线后问题通常会集中在数据隔离、性能劣化、连接耗尽这三类。下面按排查顺序把思路理一遍。6.1 数据串租户怎么查这是多租户系统里最严重的事故一旦发现别急着改代码先按这个顺序确认影响面明确是读串还是写串。读串可能是缓存 Key 缺租户维度写串大概率是 SQL 漏条件。从日志里找到最近一次租户上下文变化确认线程是不是复用了脏上下文。检查涉及的表结构确认是否有tenant_id字段以及是否走了索引。检查 DAO 层是否强制租户条件如果没有先补上。对账数据确认哪些租户的数据被串过提前准备修复脚本。修复时要记住先止血再追责。所谓止血就是确保新的写入和读取不再串数据然后才是历史数据修复。6.2 某个大租户把共享资源打满怎么处理现象通常是某个租户的报表导出任务占用了大量数据库连接其他租户大量超时。排查顺序如下先看监控确认是哪个租户在打满资源。再确认是查询问题还是写入问题慢查询日志是第一个要看的。如果是慢查询看执行计划确认索引是否命中了租户维度。如果是大批量写入看是不是可以把任务拆小降低单次执行时间。最后给大租户单独加配额和限流必要时把它的流量切到独立实例。有个经验可以分享大租户出现慢查询时不要一上来就加索引。先看 SQL 的过滤条件里有没有租户 ID如果 SQL 本身就没带租户条件加再多索引也没用。6.3 连接池被打满的排查顺序连接池被打满表象是Connection pool exhausted之类的异常但真实原因可能有很多先看数据库侧当前活跃连接数和连接来源。再看应用侧哪些线程持有连接时间过长通常可以从线程栈里定位到具体业务代码。检查是否有事务未提交这会导致连接一直不释放。检查是否有异步任务持有连接后空转。检查连接池配置里的maxActive、maxWait是否合理。这里最常见的坑是事务方法里做了耗时很长的外部调用比如在事务里调第三方接口或者执行大批量循环事务一直不提交连接一直占用。这类问题靠调整连接池参数解决不了必须优化代码。6.4 混合部署时的容量评估有些团队会采用“共享为主、大租户独立”的混合架构也就是大部分租户走共享 Schema少数超大租户单独部署。这种模式下容量评估要比单一模式更难。我的建议是共享集群只承载总量不承载单租户峰值。评估时以共享集群的总体水位为准。独立租户的容量按租户自身业务峰值评估不要和共享集群混在一起算。两套环境之间的数据同步、监控告警、备份策略都要独立设置避免一台机器或一个集群故障时互相牵连。混合模式最适合那些“绝大多数租户小且均匀极少数租户巨大且不稳定”的平台。它既保留了共享模式的经济性又给大租户提供独占保证但代价是你要维护两套部署和两套监控逻辑。最后留几句实在话多租户架构这条路我踩过最大的坑不是技术选型而是把“共享”两个字理解得太简单。共享不代表没有边界恰恰相反共享方案对边界的要求更高只不过边界从物理层转移到了代码层和中间件层。如果你刚开始做多租户我建议先把单租户链路跑通加上租户上下文、SQL 强制条件、缓存 Key 隔离和租户级监控然后再谈批量租户接入。如果已经上了共享 Schema就把限流、配额、线程池隔离和迁移回滚方案尽早补上。真正决定这套架构能走多远的不是某一个组件多强大而是你在数据边界、资源边界和运维边界上有没有形成一套可以长期执行的规范。先把规范立住再谈千万 QPS。
返回列表