Optimizing Multilingual User Data Query

90 Views Asked by At

I hope you are doing well.

I have the following query to get the users with their translated names and country information which needs speed optimization:

SELECT users.*,
       COALESCE(ANY_VALUE(trans.first_name), ANY_VALUE(fb_trans.first_name)) AS first_name,
       COALESCE(ANY_VALUE(trans.second_name), ANY_VALUE(fb_trans.second_name)) AS second_name,
       COALESCE(ANY_VALUE(trans.third_name), ANY_VALUE(fb_trans.third_name)) AS third_name,
       COALESCE(ANY_VALUE(trans.last_name), ANY_VALUE(fb_trans.last_name)) AS last_name,
       CONCAT(COALESCE(ANY_VALUE(trans.first_name), ANY_VALUE(fb_trans.first_name)), ' ', COALESCE(ANY_VALUE(trans.second_name), ANY_VALUE(fb_trans.second_name)), ' ', COALESCE(ANY_VALUE(trans.third_name), ANY_VALUE(fb_trans.third_name)), ' ', COALESCE(ANY_VALUE(trans.last_name), ANY_VALUE(fb_trans.last_name))) AS full_name,
       countries.flag AS country_flag,
       countries.code AS country_code
FROM `users`
INNER JOIN `user_translations` AS `fb_trans` ON `fb_trans`.`user_id` = `users`.`id`
INNER JOIN `languages` AS `fb_lng` ON `fb_lng`.`id` = `fb_trans`.`language_id`
INNER JOIN `languages` AS `lng` ON `lng`.`lang_code` = 'ar'
LEFT JOIN `user_translations` AS `trans` ON `trans`.`user_id` = `users`.`id`
AND `trans`.`language_id` = `lng`.`id`
INNER JOIN `countries` ON `countries`.`id` = `users`.`country_id`
WHERE `users`.`deleted_at` IS NULL
GROUP BY `users`.`id`;

A user (a row in users table) must be inserted using a transaction along with a translation (in user_translations table) which will be the first translation and the default/fallback (fb_trans and fb_lng joins)

Let's say a user has registered on our app when the app language was set to "en" (languages.id = 1). That user will be added to users table and user_translations table with language_id = 1 (en). That user's default translation is English.

Then the language changed on the app from "en" to "ar" (languages.id = 2) (Arabic), the query will try to get the Arabic translation of the user if existing, otherwise, it will get the user's first/default/fallback translation which is the English one.

Explain Query

Explain query before adding an index for deleted_at column in users table (The query triggers log_queries_not_using_indexes):

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE lng NULL ref languages_lang_code_index languages_lang_code_index 767 const 1 100.00 Using index; Using temporary; Using filesort
1 SIMPLE users NULL ALL PRIMARY,users_uuid_unique,users_country_id_foreign,users_city_id_foreign,users_district_id_foreign NULL NULL NULL 16669 10.00 Using where; Using join buffer (Block Nested Loop)
1 SIMPLE countries NULL eq_ref PRIMARY PRIMARY 4 db_name.users.country_id 1 100.00 NULL
1 SIMPLE fb_trans NULL ref user_translations_user_id_index,user_translations_language_id_index user_translations_user_id_index 8 db_name.users.id 1 100.00 NULL
1 SIMPLE fb_lng NULL eq_ref PRIMARY PRIMARY 8 db_name.fb_trans.language_id 1 100.00 Using index
1 SIMPLE trans NULL ref user_translations_user_id_index,user_translations_language_id_index user_translations_user_id_index 8 db_name.users.id 1 100.00 Using where
# Test 1: Query_time: 0.277862  Lock_time: 0.008804 Rows_sent: 16313  Rows_examined: 114731
# Test 2: Query_time: 0.218047  Lock_time: 0.000165 Rows_sent: 16313  Rows_examined: 114731
# Test 3: Query_time: 0.220287  Lock_time: 0.000153 Rows_sent: 16313  Rows_examined: 114731

Explain query after adding an index for deleted_at column in users table (Now the query does not trigger log_queries_not_using_indexes which is nice):

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE lng NULL ref languages_lang_code_index languages_lang_code_index 767 const 1 100.00 Using index; Using temporary; Using filesort
1 SIMPLE users NULL ref PRIMARY,users_uuid_unique,users_country_id_foreign,users_city_id_foreign,users_district_id_foreign,tmp_idx_deleted_at tmp_idx_deleted_at 5 const 8334 100.00 Using index condition; Using where
1 SIMPLE countries NULL eq_ref PRIMARY PRIMARY 4 db_name.users.country_id 1 100.00 NULL
1 SIMPLE fb_trans NULL ref user_translations_user_id_index,user_translations_language_id_index user_translations_user_id_index 8 db_name.users.id 1 100.00 NULL
1 SIMPLE fb_lng NULL eq_ref PRIMARY PRIMARY 8 db_name.fb_trans.language_id 1 100.00 Using index
1 SIMPLE trans NULL ref user_translations_user_id_index,user_translations_language_id_index user_translations_user_id_index 8 db_name.users.id 1 100.00 Using where
# Test 1: Query_time: 0.248500  Lock_time: 0.006678 Rows_sent: 16313  Rows_examined: 114458
# Test 2: Query_time: 0.268973  Lock_time: 0.000258 Rows_sent: 16313  Rows_examined: 114458
# Test 3: Query_time: 0.242067  Lock_time: 0.000304 Rows_sent: 16313  Rows_examined: 114458

Notes:

  • A user must have at least one row in user_translation table.
  • I had to use ANY_VALUE() or MAX() to avoid this error: SQL Error (1055): Expression #20 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'trans.first_name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by.
  • The query takes ~0.250 sec. for only 16313 rows, imagine if there are 1,000,000 rows !
  • The same query scenario is used for other tables like countries, articles, news, groups, categories.

Database Tables

Table: languages

CREATE TABLE `languages` (
    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
    `lang_name` VARCHAR(255) NOT NULL COLLATE 'utf8_unicode_ci',
    `lang_code` VARCHAR(255) NOT NULL COLLATE 'utf8_unicode_ci',
    `timestamp` VARCHAR(255) NOT NULL COLLATE 'utf8_unicode_ci',
    `created_at` TIMESTAMP NULL DEFAULT NULL,
    `updated_at` TIMESTAMP NULL DEFAULT NULL,
    PRIMARY KEY (`id`) USING BTREE,
    INDEX `languages_lang_code_index` (`lang_code`) USING BTREE
)
COLLATE='utf8_unicode_ci'
ENGINE=InnoDB;

Table: users

CREATE TABLE `users` (
    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
    `mobile` VARCHAR(20) NOT NULL COLLATE 'utf8_unicode_ci',
    `email` VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8_unicode_ci',
    `image` VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8_unicode_ci',
    `gender` TINYINT(1) NOT NULL,
    `date_of_birth` DATE NULL DEFAULT NULL,
    `timezone` VARCHAR(191) NULL DEFAULT NULL COLLATE 'utf8_unicode_ci',
    `score` BIGINT(20) NOT NULL DEFAULT '0',
    `is_searchable` TINYINT(1) NOT NULL,
    `is_deleted` TINYINT(1) NOT NULL,
    `role` TINYINT(4) NOT NULL,
    `status` TINYINT(4) NOT NULL,
    `uuid` VARCHAR(255) NOT NULL COLLATE 'utf8_unicode_ci',
    `country_id` INT(10) UNSIGNED NULL DEFAULT NULL,
    `city_id` INT(10) UNSIGNED NULL DEFAULT NULL,
    `district_id` INT(10) UNSIGNED NULL DEFAULT NULL,
    `deleted_at` TIMESTAMP NULL DEFAULT NULL,
    `created_at` TIMESTAMP NULL DEFAULT NULL,
    `updated_at` TIMESTAMP NULL DEFAULT NULL,
    PRIMARY KEY (`id`) USING BTREE,
    UNIQUE INDEX `users_uuid_unique` (`uuid`) USING BTREE,
    INDEX `users_country_id_foreign` (`country_id`) USING BTREE,
    INDEX `users_city_id_foreign` (`city_id`) USING BTREE,
    INDEX `users_district_id_foreign` (`district_id`) USING BTREE,
    INDEX `tmp_idx_deleted_at` (`deleted_at`) USING BTREE,
    CONSTRAINT `users_city_id_foreign` FOREIGN KEY (`city_id`) REFERENCES `cities` (`id`) ON UPDATE RESTRICT ON DELETE SET NULL,
    CONSTRAINT `users_country_id_foreign` FOREIGN KEY (`country_id`) REFERENCES `countries` (`id`) ON UPDATE RESTRICT ON DELETE SET NULL,
    CONSTRAINT `users_district_id_foreign` FOREIGN KEY (`district_id`) REFERENCES `districts` (`id`) ON UPDATE RESTRICT ON DELETE SET NULL
)
COLLATE='utf8_unicode_ci'
ENGINE=InnoDB;

Table: user_translations

CREATE TABLE `user_translations` (
    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
    `first_name` VARCHAR(255) NOT NULL COLLATE 'utf8_unicode_ci',
    `second_name` VARCHAR(255) NOT NULL COLLATE 'utf8_unicode_ci',
    `third_name` VARCHAR(255) NOT NULL COLLATE 'utf8_unicode_ci',
    `last_name` VARCHAR(255) NOT NULL COLLATE 'utf8_unicode_ci',
    `user_id` BIGINT(20) UNSIGNED NOT NULL,
    `language_id` BIGINT(20) UNSIGNED NOT NULL,
    `created_at` TIMESTAMP NULL DEFAULT NULL,
    `updated_at` TIMESTAMP NULL DEFAULT NULL,
    PRIMARY KEY (`id`) USING BTREE,
    INDEX `user_translations_user_id_index` (`user_id`) USING BTREE,
    INDEX `user_translations_language_id_index` (`language_id`) USING BTREE
)
COLLATE='utf8_unicode_ci'
ENGINE=InnoDB;

Table: countries

CREATE TABLE `countries` (
    `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(191) NOT NULL COLLATE 'utf8mb4_general_ci',
    `code` VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
    `wiki_id` VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
    `flag` VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci',
    `is_deleted` INT(11) NOT NULL DEFAULT '0',
    `timestamp` BIGINT(20) NULL DEFAULT '0',
    `created_at` TIMESTAMP NULL DEFAULT NULL,
    `updated_at` TIMESTAMP NULL DEFAULT NULL,
    PRIMARY KEY (`id`) USING BTREE,
    INDEX `countries_code_index` (`code`) USING BTREE,
    INDEX `countries_flag_index` (`flag`) USING BTREE
)
COLLATE='utf8mb4_unicode_ci'
ENGINE=InnoDB;
0

There are 0 best solutions below