Работа с несколькими базами данных MySQL одновременно
Предположим, что вы разрабатываете SaaS-продукт, связанный с электронной торговлей, где каждый пользователь управляет своей собственной небольшой базой данных (MySQL). Мы гарантируем, что данные пользователя остаются полностью конфиденциальными. В то же время система обеспечивает уникальное разделение данных.
При успехе SaaS-продукта он привлекает внимание новых пользователей, и их количество достигает 100 000. Теперь возникает необходимость изучить, как эти пользователи взаимодействуют с вашим программным обеспечением: сколько продуктов они добавляют, сколько заказов размещают и так далее. Для получения этих данных требуется выполнять запросы ко всем базам данных всех пользователей. Как решить эту проблему?
Представим, что у нас есть одна обширная таблица, внутри которой содержатся данные всех пользователей в виде отдельных подтаблиц. Просто задав запросы к половине этой гигантской таблицы, используя операции JOIN, GROUP BY и т. д., можно легко получить информацию из всех таблиц одновременно.
Но как создать такую объединенную таблицу? Для этого предлагаю воспользоваться инструментарием реляционной системы управления базами данных MySQL. Один из таких инструментов — это представления (VIEW), которые позволяют переосмыслить основные структуры данных. Сначала создадим представление, объединяющее таблицы всех пользователей. Эти таблицы будут соединены друг с другом.
Таким образом, мы будем создавать представление, включающее ВСЕ таблицы в одном VIEW. Проходя через этот процесс для каждой таблицы, мы сможем обеспечить наличие всех данных из баз данных в удобном виде. Каким образом будет выглядеть такое представление?
CREATE VIEW sample_table AS (
SELECT "tenant_1" AS tenant, t.* FROM tenant_1.sample_table AS t
UNION ALL
SELECT "tenant_2" AS tenant, t.* FROM tenant_2.sample_table AS t
...
UNION ALL
SELECT "tenant_N" AS tenant, t.* FROM tenant_N.sample_table AS t
)В каждом создаваемом представлении ко всем колонкам оригинальных таблиц добавляется дополнительная колонка «арендатор» (пользователь), содержащая имя базы данных пользователя, из которой извлекается запись. Это обеспечивает удобство в определении владельца данных. Однако при написании запросов JOIN необходимо проявлять осторожность, поскольку условиям JOIN также требуется добавление этой колонки:
SELECT
pp.tenant, pp.name, ppg.num
FROM
products__products AS pp
LEFT JOIN
products__product_gtins AS ppg ON (pp.id = ppg.product_id
AND pp.tenant = ppg.tenant)Теперь остается лишь создать такое представление для всех таблиц в базе данных. Для этой задачи можно использовать любой язык программирования, но важно использовать хранимые процедуры. Есть несколько причин:
- Отсутствие зависимостей: В СУБД уже присутствует среда выполнения, что облегчает выполнение хранимых процедур.
- Простота обновления представлений: Обновление представлений не представляет сложности и сводится к выполнению SQL-команды с любого предпочитаемого клиента.
Хранимая процедура для формирования представления одной таблицы.
CREATE PROCEDURE `update_table_view`(IN tbl_name VARCHAR(100), IN db_pattern_re VARCHAR(100))
BEGIN
DECLARE all_dbs_view LONGTEXT;
DECLARE all_dbs_done INT DEFAULT 0;
DECLARE all_dbs_indx INT DEFAULT 0;
DECLARE cur_tenant_db VARCHAR(100);
-- (A)
DECLARE all_dbs_cur CURSOR FOR SELECT `schema_name` FROM information_schema.schemata WHERE `schema_name` REGEXP db_pattern_re;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET all_dbs_done = 1;
-- (B)
SET all_dbs_view = CONCAT("CREATE VIEW ", tbl_name, " AS ");
SET all_dbs_done = 0;
SET all_dbs_indx = 0;
-- (C)
OPEN all_dbs_cur;
all_dbs_loop: LOOP
FETCH all_dbs_cur INTO cur_tenant_db;
IF all_dbs_done = 1 THEN LEAVE all_dbs_loop; END IF;
-- (D)
IF all_dbs_indx > 0 THEN SET all_dbs_view = CONCAT(all_dbs_view, " UNION ALL "); END IF;
SET all_dbs_view = CONCAT(all_dbs_view, "SELECT \"", cur_tenant_db, "\" AS tenant, t_", all_dbs_indx, ".* FROM `", cur_tenant_db, "`.`", tbl_name, "` AS t_", all_dbs_indx);
SET all_dbs_indx = all_dbs_indx + 1;
END LOOP all_dbs_loop;
CLOSE all_dbs_cur;
-- (E)
SET @drop_view = CONCAT("DROP VIEW IF EXISTS ", tbl_name);
PREPARE drop_view_stm FROM @drop_view; EXECUTE drop_view_stm; DEALLOCATE PREPARE drop_view_stm;
-- (F)
SET @all_dbs_view_v = all_dbs_view;
PREPARE all_dbs_view_stm FROM @all_dbs_view_v; EXECUTE all_dbs_view_stm; DEALLOCATE PREPARE all_dbs_view_stm;
END
Хранимая процедура принимает два аргумента:
tbl_name: имя таблицы, для которой создаётся VIEW;db_pattern_re: регулярное выражение для исключающей фильтрации баз данных, которые будут включены в VIEW.
A) Сначала надо объявить курсор для итеративного обхода всех БД, включённых в представление. Чтобы отфильтровать только те БД, которые нам нужны, используем регулярное выражение db_pattern_re. Обработчик CONTINUE HANDLER позаботится о том, чтобы цикл остановился после итеративного обхода всех найденных БД.
B) Инициализируем переменную all_dbs_view, содержащую нашу полную инструкцию CREATE VIEW. Переменная может оказаться довольно длинной (LONGTEXT), в зависимости от числа прошедших фильтрацию БД.
C) Теперь открываем курсор и начинаем итеративный обход каждой прошедшей фильтрацию БД. Оператор IF выполнит проверку на наличие переменной all_dbs_done на каждом этапе. При запуске обработчика CONTINUE HANDLER переменная будет иметь значение 1.
D) Первая итерация не требует ставить в начало UNION ALL, а вот все последующие будут ставить. Следующая строчка конкатенирует, то есть добавляет текущий оператор SELECT, содержащий все записи из таблицы БД конкретного пользователя:
SELECT "tenants_DB" AS tenant, t.* FROM tenants_DB.some_table AS tE) Прежде чем создавать VIEW, необходимо убедиться, что предыдущее (если оно существовало) удалено.
F) И, наконец, запускаем саму инструкцию, создающую VIEW.
Теперь можно выполнить процедуру создания представления VIEW одной таблицы:
CALL update_table_view("sample_table", "tenant_[0-9]+");Хранимая процедура для итеративного обхода всех таблиц
CREATE PROCEDURE `update_all_views`(IN db_first VARCHAR(100), IN db_pattern_re VARCHAR(100))
BEGIN
DECLARE all_tbls_done INT DEFAULT 0;
DECLARE cur_tbl VARCHAR(100);
-- A
DECLARE all_tbls_cur CURSOR FOR SELECT `table_name` FROM information_schema.tables WHERE table_schema = db_first;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET all_tbls_done = 1;
SET all_tbls_done = 0;
OPEN all_tbls_cur;
-- B
all_tbls_loop: LOOP
FETCH all_tbls_cur INTO cur_tbl;
IF all_tbls_done = 1 THEN LEAVE all_tbls_loop; END IF;
-- C
CALL update_table_view(cur_tbl, db_pattern_re);
END LOOP all_tbls_loop;
CLOSE all_tbls_cur;
ENDХранимая процедура принимает два аргумента:
db_first: имя базы данных, которое будет использовано для вывода списка всех таблиц. Это может быть любая ваша БД;db_pattern_re: регулярное выражение для исключающей фильтрации баз данных, которые будут включены вVIEW.
A) Объявляем курсор для итеративного обхода всех таблиц, найденных в базе данных db_first. Обработчик CONTINUE HANDLER позаботится о том, чтобы цикл остановился после итеративного обхода всех обнаруженных таблиц.
B) Открываем курсор и начинаем итеративный обход каждой найденной таблицы. Оператор IF выполнит проверку на наличие переменной all_tbls_done на каждом этапе. При запуске обработчика CONTINUE HANDLER переменная будет иметь значение 1.
C) Получив значение текущей таблицы, хранимой в переменной cur_tbl, можно выполнить хранимую процедуру, которая создаст VIEW специально для cur_tbl.
При выполнении хранимой процедуры:
CALL update_all_views("tenant_1", "tenant_[0-9]+");получаем:
- список всех представлений со всеми таблицами из базы данных
tenant_1; - ОБЪЕДИНЕНИЕ одинаковых таблиц всех арендаторов, чем и является, по сути, каждое представление.
Вот и всё!
Преимущества
- нет зависимостей (всё выполняется в сервере MySQL);
- может использоваться из любого клиента MySQL;
- привычная модель данных (по сути, одна и та же);
- легко устанавливать, обновлять, использовать;
- можно выполнять произвольный запрос.
Недостатки
- при объединении таблиц необходимо добавлять дополнительное условие;
- запросы не отличаются большой скоростью (зависит от количества арендаторов), но довольно быстрые;
- масштабирование ограничено: до 100 000 арендаторов.
Заключение
В созданном нами VIEW сейчас приблизительно 340 таблиц и 250 арендаторов. Никаких тестов на его масштабирование пока не проводилось. Отклик на запросы довольно быстрый, а вообще было интересно: мы много чего узнали, выполняя эти простые запросы.
Вряд ли стоит использовать это представление на большом количестве арендаторов — тысячах и тем более миллионах — а вот на небольшой выборке (скажем, с 34-го арендатора по 76-й) вполне можно:
CALL update_all_views("tenant_34", "tenant_(3[4-9]|[4-6][0-9]|7[0-6])$");А вообще это очень удобный инструмент для проведения анализа ваших арендаторов с целью получения ответов на конкретные вопросы. Нужно всего лишь выполнить несколько простых запросов и немного поэкспериментировать.
Внимание
Как и в любом программном решении, здесь есть подводные камни, о которых мы ещё не упоминали.
1) При выполнении команды-SQL для создания VIEW сервер MySQL может выбрасывать исключение вроде такого:
Prepared statement needs to be re-preparedНаше решение было таким:
SET GLOBAL table_definition_cache = 2800У вас это значение (2800) может отличаться в зависимости от количества арендаторов.
2) При выполнении запросов с условием на конкретном арендаторе
ELECT * FROM sample_table WHERE tenant = "tenant_1"можете получить исключения (в зависимости от того, как вы создавали базу данных):
Illegal mix of collations (utf8mb4_general_ci,COERCIBLE) and (utf8mb4_unicode_ci,IMPLICIT) for operation ‘=’Решение — смена кодировки (символов) имени базы данных при создании представления. Добавляем вызов функции CONVERT в операторе SELECT:
SELECT CONVERT("tenants_DB" USING utf8mb4) AS tenant, t.* FROM tenants_DB.some_table AS tВместо utf8mb4 используйте значение, заданное вами для кодирования при создании базы данных.
3) Время ожидания подключения тоже может стать проблемой. На создание всех этих представлений может потребоваться время. В нашем случае на это ушло 5 минут. Так что позаботьтесь о том, чтобы время ожидания подключения в вашем клиенте было достаточным.
4) Лучше готовить VIEW и выполнять запросы на сервере реплики, чтобы не перегружать рабочие серверы. Иначе запросы очень быстро могут стать тяжёлыми.
5) Для тестирования этого метода использовался сервер MySQL Community Server 5.7.20.
