SaaS软件架构设计系列 | 2-2 从简约的多租户设计开始

226 阅读12分钟

从简约的多租户设计开始

多租户的数据隔离

我们可以先将数据分为结构化和非结构化数据。结构化数据存储在关系型数据库,非结构化数据存储在对象存储中。我们先来看看数据库的设计。

数据库设计

多租户体系需要对客户的数据访问进行隔离,在数据库层面可以选择租户字段、表、数据库/Schema、数据库实例几种同的方式。下面我们来分析一下各方案的优缺点。

  • 租户字段隔离:所有租户同一数据对象存储在同一张数据表中,通过租户ID字段或分区进行租户隔离。需要在代码访问SQL时统一添加租户过滤条件的层,靠手工添加肯定会出现遗漏的情况。但在开发过程中、系统管理和维护的时候总会涉及到手写SQL执行的时候,需要人工来保障风险太高。后续数据库的横向扩展还需要额外的确定租户所在的数据库实例。整体代价大、风险高、扩展弱,不推荐;
  • 表隔离:同一数据对象每个租户一张表,数据结构相同,表名不同。需要在代码访问SQL时统一添给表名添加后缀,同租户字段隔离一样,手工处理的场景会比较麻烦,特别是联表查询的场景。而且维护时需要循环所有租户表执行SQL。整体代价大、扩展弱,不推荐。
  • 数据库/Schema隔离:每个租户一个数据库(有的的Schema,后面统称数据库),相同数据对象的表结构一样,访问时根据租户确定库。该方案的好处是只需要在建立连接后根据租户指定对应的库就可以,后面的SQL执行所有租户都是一样的;横向扩展的时候只需要添加数据库实例就可以。缺点是在维护时需要循环所有的租户库执行SQL语句。
  • 数据库实例隔离:逻辑上和数据库隔离相同,但成本比较高。如果确实有需要,数据库隔离的方式也很容易切换到该方式。

根据上面的分析,我们推荐使用数据库隔离的方式。每个租户一个数据库。另外我们还需要一个数据库来管理多租户的信息。考虑到新增租户的场景,我们需要为新租户创建一套数据库结构以及库里的基础数据。这里一般有两种方法:

  • 一种是保存创建的数据库脚本,在迭代更新的同时更新脚本,创新租户时,使用脚本初始化数据库。好处是可以根着工程代码一起走,有清晰的版本和历史记录。
  • 另一种是使用一个模板数据库,在创新租户的时,复制模板库的结构和内容。优点是迭代的SQL和日常运维的SQL都可以在模板库同样执行,通过工具化处理,不会遗漏东西。

因为整体的数据库脚本的生成,在迭代过程中是一个额外的工作,不仅带来成本,而且可能会遗漏。另外在日常维护的过程中,调整了业务库的数据,可能会忘记调整模板库。基于靠人不如靠工具,人一定会犯错的理念,我们更推荐第二种使用模板库的方案,由工具保证每个更新升级和问题修复的SQL都会在模板库执行到。

我们需要注意到,有些SQL只能在模板库执行,比如调整新增租户的某个业务参数,不能将已有的租户的业务参数改掉。因此工具设计的时候需要支持只在模板库执行。当然也存在只对部分租户进行数据修复的场景,目前来看,可以把模板库也当成租户库,那只需要设计成能选择部分租户执行就可以了。但需要注意到模板库的更新是一种迭代行为,租户库的数据修复是一种运维行为,最好能区分开,便于管理和追溯。

根据以上的分析,我们可以得出一个初步的数据库架构设计如下:

image.png

关于数据库主键: 部分实践中可能会建议使用自增ID,在SaaS的场景下,每个租户的数据是隔离的,使用自增ID看似没有问题。但SaaS服务的客户往往是同一个行业的,他们可能会存在收购合并的情况,这时候就会要求合并数据,自增ID会导致很多的麻烦。另一个场景是将所有的租户数据合并在一起进行分析,使用自增ID就需要加上租户作为联合主键,包括清洗分析都需要考虑此问题。所以建议使用全局唯一ID作为主键。分布式场景可以考虑雪花算法。

文件存储设计

当前各个公有云平台基本上都提供对象存储的产品,基本上都支持S3协议,而且功能强大,性能好,管理方便。所以建议非结构化文件使用对象存储。在多租户体系下,最好能够对租户存储的文件有一个隔离,方便管理和迁移。对象存储通常使用Bucket(桶)来进行隔离,但很多云平台对桶数量有限制,而且桶作为重要的权限管理对象,不适合用于租户隔离。所以这里建议按访“问权限\租户\业务”的层级来进行划分对象存储的内容。可以通过封装代码来实现在存储对象时加,自动上租户目录路径。

在对象存储的安全方面,建议永远不要开放公共写和列出目录的功能,容易产生较大的安全问题。

对象存储实际上没有物理的目录,目录只是路径中体现的一个逻辑概念,但在管理、数据迁移的时候可以按目录进行管理。

我们可能有一些可以公开访问的图片、文档,可以放到一个公共读权限的桶中,如果能够做到没有公共读,全是私有读写当然更好。其他的可以按安全需求不同放入不同的桶,比如特别敏感的信息可以放入加密存储的桶,临时文件可以放入一个会过期自动删除的桶。

建议一个系统只有一个公共读的桶,而且在使用方面需要严格规范,只存放非业务数据,最好从代码层面封装对象存储的访问,公共读的桶写入使用单独的方法或参数,便于管理和排查。对于需要公开访问的业务文件,可以获取有时间限制的临时链接用于访问私有桶中的文件。

因此对象存储的目录设计大致如下:

image.png

多租户访问设计

下面我们分登录和会话(Session)两个方面来讨论多租户访问的设计。

登录设计

因为不同租户的数据是隔离的,用户也是隔离的,所以在用户使用系统时,就需要知道是哪个租户的用户。通常有以下几种方式:

  • 用户登录时填写租户代码加用户名、密码登录。
  • 在请求信息中包含租户代码,包括URL、请求参数、请求体、Cookie、HTTP头等。
  • 使用独立的域名。

因为租户有可能会要求修改登录时的租户代码,所以登录时使用的租户代码和数据库中代表租户的代码最好分开。

我们分场景来讨论一下这些方法的使用。

  • PC端CRM登录:这种一般是租户的员工、合作伙伴使用的场景。在服务端收到登录信息的时,根据租户代码在多租户管理服务找到对应租户信息,确定数据库连接,后面就是常见的用户验证流程。在体验上,可以在用户登录成功后,在客户端记住用户的租户信息,下次登录就不需要重新输入租户代码了。考虑到用户可能换到其他公司、兼职等情况,还是需要预留修改租户的功能。
  • 通过公众号访问:这种一般是租户的C端用户访问,不可能让C端用户来选择租户。可以考虑在公众号菜单、推送或分享给C端用户的链接中带上租户代码。
  • APP端CRM登录:可以考虑使用PC端相同的方式,为了符合APP上的使用习惯和追求极致体验,可能会考虑使用手机+验证码的方式登录。因为用户手机号分散在各个租户中,所以这里要处理好性能问题。租户不多的时候一个一个库找可能勉强能接受,但不能维持太久。正常还是需要考虑有一个用户手机号到租户的映射表,这个表需要在租户添加、删除用户时进行刷新(一般采用异步,做到秒级同步能够满足大部分场景)。一个用户同时在多个租户存在,需要考虑让用户选择租户的交互页面,未确定租户前不能访问其他页面。
  • 使用独立域名:一般对品牌有要求的租户或业务,需要使用租户独立的域名。一般会给租户分配一个SaaS系统的子域名,然后用户将自己的域名通过CNAME转发过来。系统通过子域名就能够确定是哪个租户。如果SaaS使用统一的APP,在登录确定租户前,还是无法使用这个独立的域名的,所以APP的登录还是需要考虑上面PC端的方式,也需要考虑切换租户的能力。

会话设计

我们从租户隔离、安全、性能三个方面讨论一下会话的设计。

  • 租户隔离:因为会话是客户端和服务端的契约,所以我们的实践中并没有针对会话进行租户的隔离,实际场景中也没碰到过类似的需求。
  • 安全方面:一般在登录后,为了持续区分请求对应租户的需要(比如问题排查场景、后面要谈到的分流场景),会在客户端保留会话级的租户信息(PC用会话级Cookie,APP一般会用Http头)。但出于安全考虑,会话对应的租户,不应该依赖客户端请求,而应该使用登录时写在服务端会话信息中的和租户,避免恶意用户修改请求来获取非登录租户的权限。
  • 性能方面:如果用户量较大,可以考虑将会话信息保存在分布式缓存中。因为每个请求进来后,都需要确定租户的数据库连接信息,所以应该缓存数据库连接信息,有的语言可以使用连接池。数据库连接信息不建议放在会话中,因为租户数据库可能会进行迁移,放在会话中会增加缓存更新的难度。使用手机号登录的场景,也可以考虑使用LRU策略缓存手机号-租户映射信息。

综上所述,我们可以看一下多租户访问的设计概览如下:

image.png

注意:上图为简图,实际过程还需要考虑诸如缓存失效、缓存更新和数据一致性等问题。如果是APP使用手机号登录,需要设计租户用户数据与手机号-租户映射数据的同步,在登录时也需要进行验证码校验的处理。网上有较多详细的好文章可以参考。

第三方集成

在需求分析中,有一条是要进行微信公众号的统一管理,可以作为SaaS系统第三方集成的一个代表来分析。对于SaaS系统,可以使用微信的第三方平台来管理客户的公众号。第一条建议是对第三方调用进行统一的封装,避免第三方接口调整或切换第三方时导致到处需要修改。同时需要考虑第三方回调的租户定位问题,如果到处写,多租户的逻辑调整也会导致到处改的问题。下面我们来看一下第三方回调如何区分租户:

  • 前端URL跳转:一般是跳转到第三方页面再跳回来,比如微信公众号的网页授权、认证中心单点登录。前端URL跳转一般在调用时,可以指定跳转回来的URL,可以在URL中带上租户的信息。
  • 后端异步回调:一般异步回调可以指定URL,可在以URL中带上租户信息。
  • 后端通知:后端通和一般为固定的地址,无法附带租户信息,需要通过业务数据来区分租户。前端跳转和后端异步回调如果是固定URL,也只能通过数据数据来区分租户。

通过业务数据来定位租户时,如果要考虑性能,可以考虑在发起时或初始化缓存一段时间的业务对象ID(比如公众号代码)和租户的对应关系,对于数据量大的,可以考虑LRU算法来处理。

如果第三方消息特别多且流量不确定,则需要考虑使用消息队列来消峰,保障系统的稳定性。

至此一个简单但相对完备的多租户架构设计就完成了,下一回我们讨论一下相关的常规架构设计选问题。