經過租戶id實現的SaaS方案

概況

項目開發到一半,用戶忽然提出須要多個分公司共同使用,這種須要將系統設計成SaaS架構,將各個分公司的數據進行隔離。mysql

SaaS實現的方案

  • 獨立數據庫git

    每一個企業 獨立的物理數據庫,隔離性好,成本高。sql

  • 共享數據庫、獨立schemashell

    就是一臺物理機,多個邏輯數據庫,oracle叫作schema,mysql叫作database,每一個企業獨立的schema。數據庫

  • 共享數據庫、數據庫表(本次採用):express

    在表中添加「企業」或者「租戶」字段區分是哪一個企業的數據。操做的時候根據「租戶」字段去查詢相應的數據。緩存

    優勢: 全部租戶使用同一數據庫,因此成本低廉。安全

    缺點:隔離級別低,安全性低,須要在開發時加大對安全的開發量,數據備份和恢復最困難。mybatis

改造思路

  1. 本次採用共享數據庫、數據庫表的SaaS方案。改造時須要作如下工做:
  • 建立租戶信息表。
  • 先要將全部的表添加租戶id字段tenant_id。用於關聯租戶信息表。
  • tenant_id和原始表id建立聯合主鍵。注意主鍵的順序,原表主鍵必須在左邊。
  • 將表修改成分區表。
  1. 改造後,在添加租戶信息的時候,同時在全部表中添加該租戶的分區,分區用於保存該租戶的數據。
  2. 在後續增長記錄時,須要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`;
複製代碼
  1. 參考建表語句,建立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
複製代碼
  1. 驗證分區狀況:
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個分區

  1. 將數據複製到copy表中
INSERT INTO `sys_log_copy` SELECT * FROM `sys_log`
複製代碼
  1. 刪除表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">
        <![CDATA[
                call add_table_partition (
                        #{_tenantId,mode=IN,jdbcType=VARCHAR});
        ]]>
</select>
複製代碼

若是您感受有收穫,請點贊支持下,謝謝!
相關文章
相關標籤/搜索