概况
项目开发到一半,用户突然提出需要多个分公司共同使用,这种需要将系统设计成SaaS架构,将各个分公司的数据进行隔离。
SaaS实现的方案
独立数据库
每个企业 独立的物理数据库,隔离性好,成本高。
共享数据库、独立schema
就是一台物理机,多个逻辑数据库,oracle叫做schema,mysql叫做database,每个企业独立的schema。
共享数据库、数据库表(本次采用):
在表中添加“企业”或者“租户”字段区分是哪个企业的数据。操作的时候根据“租户”字段去查询相应的数据。
缺点:隔离级别低,安全性低,需要在开发时加大对安全的开发量,数据备份和恢复最困难。
改造思路
本次采用共享数据库、数据库表的SaaS方案。改造时需要做以下工作:
先要将所有的表添加租户id字段tenant_id。用于关联租户信息表。
将tenant_id和原始表id创建联合主键。注意主键的顺序,原表主键必须在左边。
改造后,在添加租户信息的时候,同时在所有表中添加该租户的分区,分区用于保存该租户的数据。
在后续增加记录时,需要tenant_id字段的值,在删改查中,都需要在where条件中以tenant_id为条件来操作某个租户的数据。
测试环境介绍
测试库中有5张表,我下文使用sys_log表进行测试。
sys_log的建表语句为:
CREATE TABLE `sys_log` ( `log_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键' , `type` TINYINT (1 ) DEFAULT NULL COMMENT '类型' , `content` VARCHAR (255 ) DEFAULT NULL COMMENT '内容' , `create_id` BIGINT (18 ) DEFAULT NULL COMMENT '创建人ID' , `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' , `tenant_id` INT NOT NULL , PRIMARY KEY (`log_id` ,`tenant_id` ) USING BTREE ) ENGINE =INNODB DEFAULT CHARSET =utf8 ROW_FORMAT=DYNAMIC COMMENT ='系统日志'
表添加租户id字段
找出未添加租户
id(tenant_id)
字段的表。
SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'my' -- my 是我的测试数据库名称 AND table_name NOT IN (SELECT t.table_name FROM (SELECT table_name, column_name FROM information_schema.columns WHERE table_name IN (SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'my' )) t WHERE t.column_name = 'tenant_id' ) ;
执行,找到两个符合条件的表,在数据库进行确认,确实表中没tenant_id字段。
创建租户信息表
仅供参考,用于保存租户信息
CREATE TABLE `t_tenant` ( `tenant_id` varchar (40 ) NOT NULL DEFAULT 'c12dee54f652452b88142a0267ec74b7' COMMENT '租户id' , `tenant_code` varchar (100 ) DEFAULT NULL COMMENT '租户编码' , `name` varchar (50 ) DEFAULT NULL COMMENT '租户名称' , `desc` varchar (500 ) DEFAULT NULL COMMENT '租户描述' , `logo` varchar (255 ) DEFAULT NULL COMMENT '公司logo地址' , `status` smallint (6 ) DEFAULT NULL COMMENT '状态1有效0无效' , `create_by` varchar (100 ) DEFAULT NULL COMMENT '创建者' , `create_time` datetime DEFAULT
NULL COMMENT '创建时间' , `last_update_by` varchar (100 ) DEFAULT NULL COMMENT '最后修改人' , `last_update_time` datetime DEFAULT NULL COMMENT '最后修改时间' , `street_address` varchar (200 ) DEFAULT NULL COMMENT '街道楼号地址' , `province` varchar (20 ) DEFAULT NULL COMMENT '一级行政单位,如广东省,上海市等' , `city` varchar (20 ) DEFAULT NULL COMMENT '城市, 如广州市,佛山市等' , `district` varchar (20 ) DEFAULT NULL COMMENT '行政区,如番禺区,天河区等' , `link_man` varchar (50 ) DEFAULT NULL COMMENT '联系人' , `link_phone` varchar (50 ) DEFAULT NULL COMMENT '联系电话' , `longitude` decimal (10 ,6 ) DEFAULT NULL COMMENT '经度' , `latitude` decimal (10 ,6 ) DEFAULT NULL COMMENT '纬度' , `adcode` varchar (8 ) DEFAULT NULL COMMENT '区域编码,用于通过区域id快速匹配后展示, 如广州是440100' , PRIMARY KEY (`tenant_id` ) USING BTREE ) ENGINE =InnoDB DEFAULT CHARSET =utf8 COMMENT ='租户的基本信息表' ;
将所有表添加tenant_id字段
DROP PROCEDURE IF EXISTS addColumn ; DELIMITER ?CREATE PROCEDURE addColumn () BEGIN -- 定义表名变量 DECLARE s_tablename VARCHAR (100 ) ; /*显示表的数据库中的所有表 SELECT table_name FROM information_schema.tables WHERE table_schema='databasename' Order by table_name ; */ #显示所有 DECLARE cur_table_structure CURSOR FOR SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'my' -- my = 我的测试数据库名称 AND table_name NOT IN (SELECT t.table_name FROM (SELECT table_name, column_name FROM information_schema.columns WHERE table_name IN (SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = 'my' )) t WHERE t.column_name = 'tenant_id' ) ; DECLARE CONTINUE HANDLER FOR SQLSTATE '02000' SET s_tablename = NULL ; OPEN cur_table_structure ; FETCH cur_table_structure INTO s_tablename ; WHILE (s_tablename IS NOT NULL) DO SET @MyQuery = CONCAT ( "alter table `" , s_tablename, "` add COLUMN `tenant_id` INT not null COMMENT '租户id'" ) ; PREPARE msql FROM @MyQuery ; EXECUTE msql ; #USING @c; FETCH cur_table_structure INTO s_tablename ; END WHILE ; CLOSE cur_table_structure ;END ? DELIMITER ;#执行存储过程 CALL addColumn () ;
实现表分区
实现的目标:在添加租户的时候实现对所有表添加分区
需要的条件:
表必须是分区表,如果不是分区表,那么需要改成分区表。
tenant_id必须和原表log_id主键组成联合主键。
将表修改成分区表
表中添加分区有三种方式:
创建临时分区表
sys_log_copy
,copy数据过来后,删除旧的sys_log,再将
sys_log_copy
修改为sys_log(本次采用,详见下文)
直接将表修改为分区表,需要原表中无数据,否则无法成功:
-- 如果表中没数据,可以直接将表进行分区 ALTER TABLE sys_log PARTITION BY LIST COLUMNS (tenant_id) ( PARTITION a1 VALUES IN (1 ) ENGINE = INNODB , PARTITION a2 VALUES IN (2 ) ENGINE = INNODB , PARTITION a3 VALUES IN (3 ) ENGINE = INNODB );
在分区表中添加新分区,需要表已经是分区表,否则无法成功:
-- 已经是分区表中添加分区 ALTER TABLE sys_log_copy ADD PARTITION ( PARTITION a4 VALUES IN (4 ) ENGINE = INNODB , PARTITION a5 VALUES IN (5 ) ENGINE = INNODB , PARTITION a6 VALUES IN (6 ) ENGINE = INNODB );
通过创建临时分区表的方式将原表转换成分区表
1.查看表建表语句:
SHOW CREATE TABLE `sys_log` ;
2.参考建表语句,创建copy表:
CREATE TABLE `sys_log_copy` ( `log_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键' , `type` TINYINT (1 ) DEFAULT NULL COMMENT '类型' , `content` VARCHAR (255 ) DEFAULT NULL COMMENT '内容' , `create_id` BIGINT (18 ) DEFAULT NULL COMMENT '创建人ID' , `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' , `tenant_id` INT NOT NULL , PRIMARY KEY (`log_id` ,`tenant_id` ) USING BTREE ) ENGINE =INNODB DEFAULT CHARSET =utf8mb4 ROW_FORMAT=DYNAMIC COMMENT ='系统日志' PARTITION BY LIST COLUMNS (tenant_id) ( PARTITION a1 VALUES IN (1 ) ENGINE = INNODB , PARTITION a2 VALUES IN (2 ) ENGINE = INNODB , PARTITION a3 VALUES IN (3 ) ENGINE = INNODB );
注意上文中的
DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC
CHARSET=utf8mb4
是因为utf8在mysql中是不健全的编码。
ROW_FORMAT=DYNAMIC
是为了避免所以长度过大后导致如下报错:
ERROR 1709 (HY000): Index column size too large. The maximum column size is 767 bytes.
也可以在my.ini配置文件中设置为true解决这个问题,但是要重启数据库,会比较麻烦。
[mysqld] innodb_large_prefix=true
3.验证分区情况:
SELECT partition_name part, partition_expression expr, partition_description descr , table_rows FROM information_schema.partitions WHERE TABLE_SCHEMA = SCHEMA () AND TABLE_NAME = 'sys_log_copy' ;
可以查看到添加的3个分区
4.将数据复制到copy表中
INSERT INTO `sys_log_copy` SELECT * FROM `sys_log`
5.删除表sys_log,再修改
sys_log_copy
表中的名字为sys_log。
编写自动创建分区的仓储过程
通过存储过程实现,在分区表中添加分区
DELIMITER ?USE `my` ?DROP PROCEDURE IF EXISTS `add_table_partition` ?CREATE DEFINER=`root` @`%` PROCEDURE `add_table_partition` (IN _tenantId INT )BEGIN DECLARE IS_FOUND INT DEFAULT 1 ; -- 用于记录游标中存在分区的表名 DECLARE v_tablename VARCHAR (200 ) ; -- 用于缓存添加分区时候的sql DECLARE v_sql VARCHAR (5000 ) ; -- 分区名称定义 DECLARE V_P_VALUE VARCHAR (100 ) DEFAULT CONCAT ('P' , REPLACE (_tenantId, '-' , '' )) ; DECLARE V_COUNT INT ; DECLARE V_LOONUM INT DEFAULT 0 ; DECLARE V_NUM INT DEFAULT 0 ; -- 定义游标,值是所有分区表的表名 DECLARE curr CURSOR FOR (SELECT t.TABLE_NAME FROM INFORMATION_SCHEMA.partitions t WHERE TABLE_SCHEMA = SCHEMA () AND t.partition_name IS NOT NULL GROUP BY t.TABLE_NAME) ; -- 如果没影响的记录,程序也继续执行 DECLARE CONTINUE HANDLER FOR NOT FOUND SET IS_FOUND=0 ; -- 获取上一步中的游标中获取到的表名的个数 SELECT COUNT (1 ) INTO V_LOONUM FROM (SELECT t.TABLE_NAME FROM INFORMATION_SCHEMA.partitions t WHERE TABLE_SCHEMA = SCHEMA
() AND t.partition_name IS NOT NULL GROUP BY t.TABLE_NAME) A ; -- 只有在存在分区表的时候才打开游标 IF V_LOONUM > 0 THEN -- 打开游标 OPEN curr ; -- 循环 read_loop : LOOP -- 声明结束的时候 IF V_NUM >= V_LOONUM THEN LEAVE read_loop ; END IF ; -- 取游标的值给变量 FETCH curr INTO v_tablename ; -- 依次判断分区表是否存在改分区,如果不存在则添加分区 SET V_NUM = V_NUM + 1 ; SELECT COUNT (1 ) INTO V_COUNT FROM INFORMATION_SCHEMA.partitions t WHERE LOWER (T.TABLE_NAME) = LOWER (v_tablename) AND T.PARTITION_NAME = V_P_VALUE AND T.TABLE_SCHEMA = SCHEMA () ; IF V_COUNT <= 0 THEN SET v_sql = CONCAT ( ' ALTER TABLE ' , v_tablename, ' ADD PARTITION (PARTITION ' , V_P_VALUE, ' VALUES IN(' , _tenantId, ') ENGINE = INNODB) ' ) ; SET @v_sql = v_sql ; -- 预处理需要执行的动态SQL,其中stmt是一个变量 PREPARE stmt FROM @v_sql ; -- 执行SQL语句 EXECUTE stmt ; -- 释放掉预处理段 DEALLOCATE PREPARE stmt ; END IF ; -- 结束循环 END LOOP read_loop; -- 关闭游标 CLOSE curr ; END IF ;END ? DELIMITER ;
调用存储过程测试
CALL add_table_partition (8 ) ;
如果表还不是分区表,那么调用存储过程会有如下报错:
错误代码: 1505 Partition management on a not partitioned table is not possible
翻译出来的意思是:“在未分区的表上进行分区管理是不可能的”。
可能会报错如下:
错误代码: 1329 No data - zero rows fetched, selected, or processed
但如果通过查询下面的
information_schema.partitions
无误,那就是添加分区成功。
可以通过在定义游标后,打开游标之前,添加如下方式解决:
DECLARE CONTINUE HANDLER FOR NOT FOUND SET IS_FOUND=0;
SELECT partition_name part, partition_expression expr, partition_description descr , table_rows FROM information_schema.partitions WHERE TABLE_SCHEMA = SCHEMA () AND TABLE_NAME = 'sys_log' ;
通过mybatis调用存储过程
<select id ="testProcedure" statementType ="CALLABLE" useCache ="false" parameterType ="string" > call add_table_partition ( #{_tenantId,mode=IN,jdbcType=VARCHAR}); ]]>select >
实现简单的数据权限
我们可能需要这种场景需求
集团公司有多个子公司,集团公司和每个子公司各自是一个租户,但是子公司下还有子公司。
不管是集团公司还是说旗下的子公司都有相应的用户(t_user)。
用户需要有查看自己公司下的数据和下面子公司的数据的权限。
从上面的场景需求中,我们知道,t_tenant表需要设计成树桩结构。下面我们进行测试。
修改上文的t_tenant表为:
CREATE TABLE `t_tenant` ( `tenant_id` VARCHAR (40 ) NOT NULL DEFAULT '0' COMMENT '租户id' , `path` VARCHAR (200 ) DEFAULT NOT NULL COMMENT '从根节点开始的id路径树,如:0-2-21-211-2111,通过"-"隔开,最末尾为自己id' , `tenant_code` VARCHAR (100 ) DEFAULT NULL COMMENT '租户编码' , `name` VARCHAR (50 ) DEFAULT NULL COMMENT '租户名称' , `logo` VARCHAR (255 ) DEFAULT NULL COMMENT '公司logo地址' , `status` SMALLINT (6 ) DEFAULT NULL COMMENT '状态1有效0无效' , `create_by` VARCHAR (100 ) DEFAULT NULL COMMENT '创建者' , `create_time` DATETIME DEFAULT NULL COMMENT '创建时间' , `last_update_by` VARCHAR (100 ) DEFAULT NULL COMMENT '最后修改人' , `last_update_time` DATETIME DEFAULT NULL COMMENT '最后修改时间' , `street_address` VARCHAR (200 ) DEFAULT NULL COMMENT '街道楼号地址' , PRIMARY KEY (`tenant_id` ) USING BTREE ) ENGINE =INNODB DEFAULT CHARSET =utf8 COMMENT ='租户的基本信息表'
修改的地方有:
添加测试数据
新增租户信息:
创建用户表(t_user),添加测试用户:
创建附件表(t_file),添加测试业务数据:
创建人字段(create_by)关联用户表(t_user),也要关联到租户(tenant_id),指明是哪个子公司的数据。
进行测试
查看tenant_id是"211"的租户信息和其下的子租户信息
SELECT tt.`tenant_id` , tt.path FROM t_tenant tt WHERE (SELECT INSTR (tt.path, "211"