数据层的多租户浅谈(SAAS多租户数据库设计)
在上一篇“浅析多租户在 Java 平台和某些 PaaS 上的实现”中我们谈到了应用层面的多租户架构,涉及到 PaaS、JVM、OS 等,与之相应的是数据层也有多租户的支持。
数据层的多租户综述
多租户(Multi Tenancy/Tenant)是一种软件架构,其定义是:
在一台服务器上运行单个应用实例,它为多个租户提供服务。
在SaaS实施过程中,有一个显著的考量点,就是如何对应用数据进行设计,以支持多租户,而这种设计的思路,是要在数据的共享、安全隔离和性能间取得平衡。
传 统的应用,仅仅服务于单个租户,数据库多部署在企业内部网络环境,对于数据拥有者来说,这些数据是自己“私有”的,它符合自己所定义的全部安全标准。而在 云计算时代,随着应用本身被放到云端,导致数据层也经常被公开化,但租户对数据安全性的要求,并不因之下降。同时,多租户应用在租户数量增多的情况下,会 比单租户应用面临更多的性能压力。本文即对这个主题进行探讨:多租户在数据层的框架如何在共享、安全与性能间进行取舍,同时了解一下市面上一些常见的数据 厂商怎样实现这部分内容。
常见的三种模式
在 MSDN 的这篇文章 Multi-Tenant Data Architecture 中,系统的总结了数据层的三种多租户架构:
- 独立数据库
- 共享数据库、独立 Schema
- 共享数据库、共享 Schema、共享数据表
独立数据库是一个租户独享一个数据库实例,它提供了最强的分离度,租户的数据彼此物理不可见,备份与恢复都很灵活;共享数据库、独立 Schema 将每个租户关联到同一个数据库的不同 Schema,租户间数据彼此逻辑不可见,上层应用程序的实现和独立数据库一样简单,但备份恢复稍显复杂; 最后一种模式则是租户数据在数据表级别实现共享,它提供了最低的成本,但引入了额外的编程复杂性(程序的数据访问需要用 tenantId 来区分不同租户),备份与恢复也更复杂。这三种模式的特点可以用一张图来概括:
图 1. 三种部署模式的异同
上图所总结的是一般性的结论,而在常规场景下需要综合考虑才能决定那种方式是合适的。例如,在占用成本上,认为独立数据库会高,共享模式较低。但如果考虑到大租户潜在的数据扩展需求,有时也许会有相反的成本耗用结论。
而多租户采用的选择,主要是成本原因,对于多数场景而言,共享度越高,软硬件资源的利用效率更好,成本也更低。但同时也要解决好租户资源共享和隔离带来的安全与性能、扩展性等问题。毕竟,也有客户无法满意于将数据与其他租户放在共享资源中。
目前市面上各类数据厂商在多租户的支持上,大抵都是遵循上文所述的这几类模式,或者混合了几种策略,这部分内容将在下面介绍。
JPA Provider
JSR 338 定义了 JPA 规范 2.1,但如我们已经了解到的,Oracle 把多租户的多数特性推迟到了 Java EE 8 中。尽管这些曾经在 JavaOne 大会中有过演示,但无论是在 JPA 2.0(JSR 317)还是 2.1 规范中,都依然没有明文提及多租户。不过这并不妨碍一些 JPA provider 在这部分领域的实现,Hibernate 和 EclipseLink 已提供了全部或部分的多租户数据层的解决方案。
Hibernate 是当今最为流行的开源的对象关系映射(ORM)实现,并能很好地和 Spring 等框架集成,目前 Hibernate 支持多租户的独立数据库和独立 Schema 模式。EclipseLink 也是企业级数据持久层JPA标准的参考实现,对最新 JPA2.1 完整支持,在目前 JPA 标准尚未引入多租户概念之际,已对多租户支持完好,其前身是诞生已久、功能丰富的对象关系映射工具 Oracle TopLink。因此本文采用 Hibernate 和 EclipseLink 对多租户数据层进行分析。
Hibernate
Hibernate 是一个开放源代码的对象/关系映射框架和查询服务。它对 JDBC 进行了轻量级的对象封装,负责从 Java 类映射到数据库表,并从 Java 数据类型映射到 SQL 数据类型。在 4.0 版本 Hibenate 开始支持多租户架构——对不同租户使用独立数据库或独立 Sechma,并计划在 5.0 中支持共享数据表模式。
在 Hibernate 4.0 中的多租户模式有三种,通过 hibernate.multiTenancy 属性有下面几种配置:
- NONE:非多租户,为默认值。
- SCHEMA:一个租户一个 Schema。
- DATABASE:一个租户一个 database。
- DISCRIMINATOR:租户共享数据表。计划在 Hibernate5 中实现。
模式1:独立数据库
如果是独立数据库,每个租户的数据保存在物理上独立的数据库实例。JDBC 连接将指向具体的每个数据库,一个租户对应一个数据库实例。在 Hibernate 中,这种模式可以通过实现 MultiTenantConnectionProvider 接口或继承 AbstractMultiTenantConnectionProvider 类等方式来实现。三种模式中它的共享性最低,因此本文重点讨论以下两种模式。
模式 2:共享数据库,独立 Schema
对于共享数据库,独立 Schema。所有的租户共享一个数据库实例,但是他们拥有独立的 Schema 或 Catalog,本文将以多租户酒店管理系统为案例说明 Hibernate 对多租户的支持和用使用方法。
图 2. guest 表结构
这是酒店客户信息表,我们仅以此表对这种模式进行说明,使用相同的表结构在 MySQL 中创建 DATABASE hotel_1 和 hotel_2。基于 Schema 的多租户模式,需要在 Hibernate 配置文件 Hibernate.cfg.xml 中设置 hibernate.multiTenancy 等相关属性。
清单 1. 配置文件 Hibernate.cfg.xml
<session-factory> <property name="connection.url"> jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf8 </property> <property name="connection.username">root</property> <property name="connection.password"></property> <property name="connection.driver_class">com.mysql.jdbc.Driver</property> <property name="dialect">org.hibernate.dialect.MySQLInnoDBDialect</property> <property name="hibernate.connection.autocommit">false</property> <property name="hibernate.cache.use_second_level_cache">false</property> <property name="show_sql">false</property> <property name="hibernate.multiTenancy">SCHEMA</property> <property name="hibernate.tenant_identifier_resolver"> hotel.dao.hibernate.TenantIdResolver </property> <property name="hibernate.multi_tenant_connection_provider"> hotel.dao.hibernate.SchemaBasedMultiTenantConnectionProvider </property> <mapping class="hotel.model.Guest" /> </session-factory>
<hibernate.tenant_identifier_resolver> 属性规定了一个合约,以使 Hibernate 能够解析出应用当前的 tenantId,该类必须实现 CurrentTenantIdentifierResolver 接口,通常我们可以从登录信息中获得 tenatId。
清单 2. 获取当前 tenantId
public class TenantIdResolver implements CurrentTenantIdentifierResolver { public String resolveCurrentTenantIdentifier() { return Login.getTenantId(); } }
< hibernate.multi_tenant_connection_provider> 属性指定了 ConnectionProvider,即 Hibernate 需要知道如何以租户特有的方式获取数据连接,SchemaBasedMultiTenantConnectionProvider 类实现了MultiTenantConnectionProvider 接口,根据 tenantIdentifier 获得相应的连接。在实际应用中,可结合使用 JNDI DataSource 技术获取连接以提高性能。
清单 3. 以租户特有的方式获取数据库连接
public class SchemaBasedMultiTenantConnectionProvider implements MultiTenantConnectionProvider, Stoppable, Configurable, ServiceRegistryAwareService { private final DriverManagerConnectionProviderImpl connectionProvider = new DriverManagerConnectionProviderImpl(); @Override public Connection getConnection(String tenantIdentifier) throws SQLException { final Connection connection = connectionProvider.getConnection(); connection.createStatement().execute("USE " + tenantIdentifier); return connection; } @Override public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException { connection.createStatement().execute("USE test"); connectionProvider.closeConnection(connection); } …… }
与表 guest 对应的 POJO 类 Guest,其中主要是一些 getter 和 setter方法。
清单 4. POJO 类 Guest
@Table(name = "guest") public class Guest { private Integer id; private String name; private String telephone; private String address; private String email; //getters and setters …… }
我们使用 ServiceSchemaBasedMain.java 来进行测试,并假设了一些数据以方便演示,如当有不同租户的管理员登录后分别进行添加客户的操作。
清单 5. 测试类 ServiceSchemaBasedMain
public class ServiceSchemaBasedMain { public static void main(String[] args) { Session session = null; Guest guest =null; List<Guest> list = null; Transaction tx = null; System.out.println("======== 租户 hotel_1 ========"); Login.setTenantId("hotel_1"); session = sessionFactory.openSession(); tx = session.beginTransaction(); guest = new Guest(); guest.setName("张三"); guest.setTelephone("56785678"); guest.setAddress("上海市张扬路88号"); guest.setEmail("zhangsan@gmail.com"); session.saveOrUpdate(guest); list = session.createCriteria(Guest.class).list(); for (Guest gue : list) { System.out.println(gue.toString()); } tx.commit(); session.close(); System.out.println("======== 租户 hotel_2 ========"); Login.setTenantId("hotel_2"); session = sessionFactory.openSession(); tx = session.beginTransaction(); guest = new Guest(); guest.setName("李四"); guest.setTelephone("23452345"); guest.setAddress("上海市南京路100号"); guest.setEmail("lisi@gmail.com"); session.saveOrUpdate(guest); list = session.createCriteria(Guest.class).list(); for (Guest gue : list) { System.out.println(gue.toString()); } tx.commit(); session.close(); } }
清单 6. 运行程序 ServiceSchemaBasedMain 的输出
======== 租户 hotel_1 ======== Guest [id=1, name=Victor, telephone=56008888, address=上海科苑路399号, email=vic@gmail.com] Guest [id=2, name=Jacky, telephone=66668822, address=上海金科路28号, email=jacky@sina.com] Guest [id=3, name=张三, telephone=56785678, address=上海市张扬路88号, email=zhangsan@gmail.com] ======== 租户 hotel_2 ======== Guest [id=1, name=Anton, telephone=33355566, address=上海南京路8号, email=anton@gmail.com] Guest [id=2, name=Gus, telephone=33355566, address=北京大道3号, email=gus@yahoo.com] Guest [id=3, name=李四, telephone=23452345, address=上海市南京路100号, email=lisi@gmail.com]
模式3:共享数据库、共享 Schema、共享数据表
在这种情况下,所有租户共享数据表存放数据,不同租户的数据通过 tenant_id 鉴别器来区分。但目前的 Hibernate 4 还不支持这个多租户鉴别器策略,要在 5.0 才支持。但我们是否有可选的替代方案呢?答案是使用 Hibernate Filter.
为了区分多个租户,我在 Schema 的每个数据表需要添加一个字段 tenant_id 以判定数据是属于哪个租户的。
图 3. 共享 Schema、共享数据表案例 E-R 图
根据上图在 MySQL 中创建 DATABASE hotel。
我们在 OR-Mapping 配置文件中使用了 Filter,以便在进行数据查询时,会根据 tenant_id 自动查询出该租户所拥有的数据。
清单 7. 对象关系映射文件 Room.hbm.xml
接下来我们在 HibernateUtil 类中通过 ThreadLocal 存放和获取 Hibernate Session,并将用户登录信息中的 tenantId 设置为 tenantFilterParam 的参数值。
清单 8. 获取 Hibernate Session 的工具类 HibernateUtil
不过 Filter 只是有助于我们读取数据时显示地忽略掉 tenantId,但在进行数据插入的时候,我们还是不得不显式设置相应 tenantId 才能进行持久化。这种状况只能在 Hibernate5 版本中得到根本改变。
清单 9. 运行程序 HotelServiceMain 输出
多租户下的 Hibernate 缓存
基于独立 Schema 模式的多租户实现,其数据表无需额外的 tenant_id。通过 ConnectionProvider 来取得所需的 JDBC 连接,对其来说一级缓存(Session 级别的缓存)是安全的可用的,一级缓存对事物级别的数据进行缓存,一旦事物结束,缓存也即失效。但是该模式下的二级缓存是不安全的,因为多个 Schema 的数据库的主键可能会是同一个值,这样就使得 Hibernate 无法正常使用二级缓存来存放对象。例如:在 hotel_1 的 guest 表中有个 id 为 1 的数据,同时在 hotel_2 的 guest 表中也有一个 id 为 1 的数据。通常我会根据 id 来覆盖类的 hashCode() 方法,这样如果使用二级缓存,就无法区别 hotel_1 的 guest 和 hote_2 的 guest。
在共享数据表的模式下的缓存, 可以同时使用 Hibernate的一级缓存和二级缓存, 因为在共享的数据表中,主键是唯一的,数据表中的每条记录属于对应的租户,在二级缓存中的对象也具有唯一性。Hibernate 分别为 EhCache、OSCache、SwarmCache 和 JBossCache 等缓存插件提供了内置的 CacheProvider 实现,读者可以根据需要选择合理的缓存,修改 Hibernate 配置文件设置并启用它,以提高多租户应用的性能。
EclipseLink
EclipseLink 是 Eclipse 基金会管理下的开源持久层服务项目,为 Java 开发人员与各种数据服务(比如:数据库、web services、对象XML映射(OXM)、企业信息系统(EIS)等)交互提供了一个可扩展框架,目前支持的持久层标准中包括:
- Java Persistence API (JPA)
- Java Architecture for XML Binding (JAXB)
- Java Connector Architecture (JCA)
- Service Data Objects (SDO)
EclipseLink 前身是 Oracle TopLink, 2007年 Oracle 将后者绝大部分捐献给了 Eclipse 基金会,次年 EclipseLink 被 Sun 挑选成为 JPA 2.0 的参考实现。
注: 目前 EclipseLink2.5 完全支持 2013 年发布的 JPA2.1(JSR 338) 。
在完整实现 JPA 标准之外,针对 SaaS 环境,在多租户的隔离方面 EclipseLink 提供了很好的支持以及灵活地解决方案。
应用程序隔离
- 隔离的容器/应用服务器
- 共享容器/应用服务器的应用程序隔离
- 同一应用程序内的共享缓存但隔离的 entity manager factory
- 共享的 entity manager factory 但每隔离的 entity manager
数据隔离
- 隔离的数据库
- 隔离的Schema/表空间
- 隔离的表
- 共享表但隔离的行
- 查询过滤
- Oracle Virtual Private Database (VPD)
对于多租户数据源隔离主要有以下方案
- Single-Table Multi-tenancy,依靠租户区分列(tenant discriminator columns)来隔离表的行,实现多租户共享表。
- Table-Per-Tenant Multi-tenancy,依靠表的租户区分(table tenant discriminator)来隔离表,实现一租户一个表,大体类似于上文的共享数据库独立Schema模式。
- Virtual Private Database(VPD ) Multi-tenancy,依靠 Oracle VPD 自身的安全访问策略(基于动态SQL where子句特性),实现多租户共享表。
本节重点介绍多租户在 EclipseLink 中的共享数据表和一租户一个表的实现方法,并也以酒店多租户应用的例子展现共享数据表方案的具体实践。
EclipseLink Annotation @Multitenant
与 @Entity 或 @MappedSuperclass 一起使用,表明它们在一个应用程序中被多租户共享, 如清单 10。
清单10. @Multitenant
@Entity @Table(name="room") @Multitenant ... publicclass Room { }
表 1. Multitenant 包含两个属性
Annotation 属性 | 描述 | 缺省值 |
---|---|---|
boolean includeCriteria | 是否将租户限定应用到 select、update、delete 操作上 | true |
MultitenantType value | 多租户策略,SINGLE_TABLE, TABLE_PER_TENANT, VPD. | SINGLE_TABLE |
共享数据表(SINGLE_TABLE)
- Metadata配置
依靠租户区分列修饰符 @TenantDiscriminatorColumn
实现。
清单11. @TenantDiscriminatorColumn
@Entity @Table(name="hotel_guest") @Multitenant(SINGLE_TABLE) @TenantDiscriminatorColumn(name="tenant_id", contextProperty="tenant.id") publicclass HotelGuest { }
或者在EclipseLink描述文件orm.xml定义对象与表映射时进行限制,两者是等价的。
清单12. orm.xml
<entity class="mtsample.hotel.model.HotelGuest"> <multitenant> <tenant-discriminator-column name="tenant_id" context-property="tenant.id"/> </multitenant> <table name="HotelGuest"/> ... </entity>
- 属性配置
租户区分列定义好后,在运行时环境需要配置具体属性值,以确定当前操作环境所属的租户。
三种方式的属性配置,按优先生效顺序排序如下
- EntityManager(EM)
- EntityManagerFactory(EMF)
- Application context (when in a Java EE container)
例如 EntityManagerFactory 可以间接通过在 persistence.xml 中配置持久化单元(Persistence Unit)或直接传属性参数给初始化时 EntityManagerFactory。
清单 13. 配置 persistence.xml
<persistence-unit name="multi-tenant"> ... <properties> <property name="tenant_id" value="开发部"/> ... </properties> </persistence-unit>
或者
清单 14. 初始化 EntityManagerFactory
按共享粒度可以作如下区分,
- EntityManagerFactory 级别
用户需要通过 eclipselink.session-name 提供独立的会话名,确保每个租户占有独立的会话和缓存。
清单 15. 为 EntityManagerFactory 配置会话名
- 共享的 EntityManagerFactory 级别
EntityManagerFactory 的默认模式, 此级别缺省配置为独立二级缓存(L2 cache), 即每个 mutlitenant 实体缓存设置为 ISOLATED,用户也可设置 eclipselink.multitenant.tenants-share-cache 属性为真以共享,此时多租户 Entity 缓存设置为 PROTECTED。
这种级别下,一个活动的 EntityManager 不能更换 tenantId。
- EntityManager 级别
这种级别下,共享 session,共享 L2 cache, 用户需要自己设置缓存策略,以设置哪些租户信息是不能在二级缓存共享的。
清单 16. 设置缓存
同样,一个活动的EntityManager不能更换tenant ID。
几点说明:
- 每个表的区分列可以有任意多个,使用修饰符 TenantDiscriminatorColumns。
清单 17. 多个分区列
@TenantDiscriminatorColumns({ @TenantDiscriminatorColumn(name="tenant_id", contextProperty="tenant.id"), @TenantDiscriminatorColumn(name = "guest_id", contextProperty="guest.id") })
- 租户区分列的名字和对应的上下文属性名可以取任意值,由应用程序开发者设定。
- 生成的 Schema 可以也可以不包含租户区分列,如 tenant_id 或 guest_id。
- 租户区分列可以映射到实体对象也可以不。
注意:当映射的时候,实体对象相应的属性必须标记为只读(insertable=false, updatable=false),这种限制使得区分列不能作为实体表的 identifier。
TenantDiscriminatorColumn
被以下 EntityManager 的操作和查询支持:
persist,find,refresh,named queries,update all,delete all 。
一租户一表(TABLE_PER_TENANT )
这种多租户类型使每个租户的数据可以占据专属它自己的一个或多个表,多租户间的这些表可以共享相同 Schema 也可使用不同的,前者使用前缀(prefix)或后缀(suffix)命名模式的表的租户区分符,后者使用租户专属的 Schema 名来定义表的租户区分符。
- Metadata配置
依靠数据表的租户区分修饰符 @TenantTableDiscriminator
实现
清单 18.
@Entity @Table(name=“CAR”) @Multitenant(TABLE_PER_TENANT) @TenantTableDiscriminator(type=SCHEMA, contextProperty="eclipselink-tenant.id") public class Car{ }
或
清单 19.
<entity class="Car"> <multitenant type="TABLE_PER_TENANT"> <tenant-table-discriminator type="SCHEMA" context-property="eclipselink.tenant-id"/> </multitenant> <table name="CAR"> </entity>
如前所述,TenantTableDiscriminatorType
有 3 种类型:SCHEMA、SUFFIX 和 PREFIX。
- 属性配置
与另外两种多租户类型一样,默认情况下,多租户共享EMF,如不想共享 EMF,可以通过配置 PersistenceUnitProperties.MULTITENANT_SHARED_EMF 以及 PersistenceUnitProperties.SESSION_NAME 实现。
清单 20.
或在 persistence.xml 配置属性。
酒店多租户应用实例(EclipseLink 共享(单)表)
数据库 Schema 和测试数据与上文 Hibernate 实现相同,关于对象关系映射(OR mapping)的配置均采用 JPA 和 EclipseLink 定义的 Java Annotation 描述。
关于几个基本操作
- 添加一个对象实例, 利用EntityManager.persist()
清单 21. 添加
public <T> void save(T t) { em.persist(t); } public <T> void saveBulk(List<T> bulk) { for(T t:bulk){ em.persist(t); } }
- 更新一个对象实例, 利用EntityManager.merge()
清单 22. 更新
public <T> void update(T t){ em.merge(t); }
- 查询, 利用EntityManager的NamedQuery,
清单 23. 多条件多结果查询
若用 JPQL 实现则示例如下:
清单 24. JPQL NamedQuery 定义
部分测试数据如下(MySQL):
hotel_admin
hotel_guest
room
运行附件 MT_Test_Hotels.zip 中的测试代码(请参照 readme)来看看多租户的一些典型场景。
清单 25. 运行测试代码
能得到输出片段如下:
清单 26. 输出
通过共享表的测试数据以及运行结果可以看到,对于多个不同的租户(hotel_admin),在添加、查找、更新操作没有显示声明租户标识的情况下,EntityManager 可以根据自身的租户属性配置
实现租户分离。在本实例,EntityManager 初始化时利用到 hotel_admin 登录后的会话上下文进行租户判断,这里不再赘述。
注:上文中提及的全部源码都可以在附件中找到。
其它方面的考虑
数据备份
独立数据库和独立Sechma的模式,为每个租户备份数据比较容易,因为他们存放在不同的数据表中,只需对整个数据库或整个Schema进行备份。
在 共享数据表的模式下,可以将所有租户的数据一起备份,但是若要为某一个租户或按租户分开进行数据备份,就会比较麻烦。通常需要另外写sql脚本根据 tenant_id来取得对应的数据然后再备份,但是要按租户来导入的话依然比较麻烦,所以必要时还是需要备份所有并为以后导入方便。
性能
独立数据库:性能高,但价格也高,需要占用资源多,不能共享,性价比低。
共享数据库,独立 Schema:性能中等,但价格合适,部分共享,性价比中等。
共享数据库,共享 Schema,共享数据表:性能中等(可利用 Cache 可以提高性能),但价格便宜,完全共享,性价比高。如果在某些表中有大量的数据,可能会对所有租户产生性能影响。
对于共享数据库的情况下,如果因为太多的最终用户同时访问数据库而导致应用程序性能问题,可以考虑数据表分区等数据库端的优化方案。
经济考虑
为了支持多租户应用,共享模式的应用程序往往比使用独立数据库模式的应用程序相对复杂,因为开发一个共享的架构,导致在应用设计上得花较大的努力,因而初始成本会较高。然而,共享模式的应用在运营成本上往往要低一些,每个租户所花的费用也会比较低。
结束语
多租户数据层方案的选择是一个综合的考量过程,包括成本、数据隔离与保护、维护、容灾、性能等。但无论怎样选择,OR-Mapping 框架对多租户的支持将极大的解放开发人员的工作,从而可以更多专注于应用逻辑。最后我们以一个 Hibernate 和 EclipseLink 的比较来结束本文。
表 2. Hibernate 与 EclipeLink 对多租户支持的比较
Hibernate | EclipseLink | |
---|---|---|
独立数据库 | 支持,通过实现 MultiTenantConnectionProvider 接口以连接独立的数据库 | 支持,为每个租户配置独立的 EntityManagerFactory |
共享数据库,独立 Schema | 支持,通过实现 MultiTenantConnectionProvider 接口以切换 Schema | 支持,使用 TABLE_PER_TENANT MultitenantType 以及 SCHEMA TenantTableDiscriminatorType |
共享数据库,共享 Schema,共享数据表 |