之前一直在做多租户改造这个需求,就是某个系统改造成支持多租户的系统,这里记录并总结这次涉及到的东西。
相关概念
多租户与SaaS
这俩个词是我做这个需求的这段时间内,最常听到的名词。首先,这俩个名词是描述俩个不同方面的:
个人理解,这俩个名字之所以现在混在一起,是因为这俩个模式加起来能很容易挣钱。多租户的一个重点就是一套代码可以给多个租户使用,而SaaS
又是一种几乎没有交付成本的交付方式。这种其实是很多外包公司的盈利模式,开发一套系统,然后全国各地去卖,等到全国都买差不多的时候,再去推另一套系统,然后再去全国各地卖。
多租户
多租户技术multi-tenancy technology
是一种软件架构技术,它是在探讨与实现如何于多用户的环境下共享相同的系统或程序组件,并且仍可确保各用户间资料的隔离性。
优点:
- 降低成本:一套系统代码的开发成本能让多个用户来承担,一旦用户足够多,这个系统的成本就非常的低
- 发版影响小:因为多个租户使用同一个实例系统,这样在软件发版时只需要发布一次,就能在所有租户的环境上生效
缺点:
- 很难针对某个租户进行定制化开发:一但某个租户需求跟这个已经开发的系统有很大的冲突,这样就需要花很大精力去适配。这也是外包很难受的一个原因
多租户的常见实现方式
多租户的一个难点就是租户之间的数据隔离,这也是我这次改造的一个重点问题。通常来说,租户之间的数据隔离主要通过俩个部分来完成的:
数据层面:data approach
在数据上进行租户隔离,能够明确的指出数据库中的这条数据是属于哪一个租户的。在这个层面上通常有三种解决方案:
- 不同租户使用不同的数据库
- 不同租户使用不同的Schema
- 不同租户使用不同编码进行区分
这三种方式优缺点网上都说的很清楚,就不说了。但是有一个很重要的一点就是:我们在设计方案时,不能将这三种方式要限定死。也就是说后期你的系统能够很容易扩展来支持这三种模式。
程序层面:application approach
虽然说多个租户使用同一个系统,但是不可避免的是某些逻辑只是针对某个租户,一定要避免程序中租户之间的数据相互污染。这个层面上有俩种解决方案:
- 不同租户使用不同进程
- 不同租户使用不同线程
这俩种方式优缺点很明显。如果使用不同进程的这种方式:在程序开发中基本就没必要考虑数据的污染,缺点就是运维成本高。如果使用不同线程的这种方式:优点就是运维成本低,缺点就要注意程序中的数据污染,比如说:线程中租户数据、程序中的缓存数据一定要做到多租户隔离。
此次改造的方案
确定需求
系统改造采用不同租户根据表中租户id字段来进行区分,也就是租户使用的是相同数据库、相同Schema
、相同表、不同的id
来区分。考虑租户之间的数据隔离、不考虑租户内部的角色数据隔离。
改造思路
因为我要修改俩个系统来支持多租户功能,所以我在思路上是尽量让俩个系统使用相同的逻辑,说白了,改动点尽量少一点。所以我的思路大概是:
1、系统所有对外接口都要求对方在请求头设置tenantId,我自己校验该字段有没有、以及字段值是否合法;
2、准备一个map,来存储所有含有tenantId字段的表;【前期可以在代码写死,后期可以在项目启动时去数据库动态查询】
3、针对项目内所有的SQL改造,准备写一个MyBatis插件,具体功能如下:
- 针对单表查询:检查是否含有tenantId,如果没有我就为其添加
- 针对多表查询:找一种工具来解析SQL,然后分析在哪个地方添加租户查询条件;【现在找到Druid的sql parser模块来实现SQL解析】
- 针对insert:检查是否含有tenantId,如果没有我就为其添加
- 针对update:检查是否含有tenantId,如果没有我就为其添加
上述对表的分析,都会根据上面的map,判断这个表是否支持多租户字段,然后才执行上述逻辑
4、为了防止特殊情况,支持在某种情况下,不执行该插件;【写一个map,根据MyBatis中的mapper的id来判断是否执行】
5、feign改造:系统之间采用的是feign,让其默认请求头设置租户字段id
6、应用程序中的线程改造
7、前端系统改造
如何验证
1、对【思路】中的第3步,对特定场景进行功能测试;一定要覆盖所有的测试用例,这也是为了后期维护这个插件做的努力
2、对于系统的Feign调用改造,也需要增加单元测试
3、对于系统的线程改造,也是靠考虑到使用场景,对所有的使用场景进行测试
4、由于系统的SQL种类复杂,需要为每种类型SQL,找到一个controller方法,然后手工验证MyBatis插件功能的正确性
方案的优缺点
因为我需求都的做差不多了,所以这次的优缺点分析有点开了上帝视角。
优点
1、解耦
这种方案不会修改到原来系统中DAO
层的代码,也就是我不会太规模的修改Mapper.xml
文件。租户的逻辑全部集中在MyBatis
插件和线程改造部分,不会与原来系统的业务代码耦合在一起。
2、后期扩展方便
刚才在多租户中数据层面的实现方式上说到,尽量避免后期很难扩展支持三种方式。这里租户的逻辑全部集中在MyBatis
插件上,无论后期是支持某些租户数据层面改造,还是全部租户数据层面的改造,修改点仅在这个插件。
缺点
1、数据污染的可能性还是有
这种方案很依赖与线程中的租户数据,一但线程中租户数据没有清除,而被后面其他线程复用的话,程序的错误就很难发现。所以,应用程序关于线程的租户数据一定要在使用完成后,及时清除。
2、SQL改造量大
这种方案代码行数最多的也就是【思路】中的第3步了。即使找到现成的第三方库来解析SQL
,你了解、使用、扩展这个库也是要花时间,而且有些功能很难实现。比如说,我现在使用阿里的Druid
来解析SQL
,然后改造完之后,再把SQL
写回MyBatis
的执行器。这时候就有可能出现SQL
注入问题,你很难改造这块代码来从技术上解决SQL
注入问题。我后期就只是在拦截器上验证租户编码的合法性来避免SQL
注入问题。
重要问题的描述
这里介绍下这次改造中,遇到的一些问题,以及改造的思路,具体的介绍等后面我整理完再写一下把。
应用中线程问题的改造
这一点就是多租户技术中程序层面的问题,我们这里多租户使用相同的进程、不同线程来执行业务逻辑,所以要注意线程的数据污染。主要是针对以下几个方面:
(1)线程复用上
因为服务器的应用线程是不断复用的,所以在用完线程后,一定要及时清除租户数据,避免出现”串号”问题。
(2)父子线程参数传递
在业务代码中可能会出现new Thread().start()
来执行业务逻辑,所以要保证先创建的线程要继承父线程的变量。
(3)线程池中参数传递
在业务代码中也会出现线程池这种技术,这里不仅要考虑父子线程,还要考虑线程复用带来的问题。解决的方案就是:transmittable-thread-local,这里还需要注意的一点是针对特定的线程池进行改造。
比如说,我们项目中用到Spring
中的ThreadPoolTaskExecutor
,而transmittable-thread-local
是没有这种线程池改造的方法,这就需要你自己手动的实现一个。
https://wangjie-fourth.github.io/2020/12/27/knowledge/java/ThreadLocal/
应用中sql改造
这就是【思路】中的第3步,也是改造的一个大的方面。这里我是采用阿里巴巴的Druid
来解析SQL
,虽然说是不完美,但我一时半会找不到替代的方案,而且也不影响主要功能,所以还是接着用这个。
应用中的数据问题
这一点其实不算是多租户改造问题,而是系统原本的问题。就是并发修改数据问题,有点类似CAS
中的ABA
问题。这里提出这个问题和解决方案,但在这次改造中并没有去解决这一块问题。
应用中feign的改造
这个功能是为了减少后期系统交互中,每次都需要手动在Feign
指定请求头。
https://zh.wikipedia.org/wiki/%E8%BD%AF%E4%BB%B6%E5%8D%B3%E6%9C%8D%E5%8A%A1
https://baike.baidu.com/item/%E5%A4%9A%E7%A7%9F%E6%88%B7%E6%8A%80%E6%9C%AF/10061761?fr=aladdin
https://stackoverflow.com/questions/40262132/how-to-add-a-request-interceptor-to-a-feign-client
https://www.cnblogs.com/yuananyun/p/8093853.html
https://www.cnblogs.com/Jeely/p/12325680.html