Changez votre moteur : comment migrer votre pile MySQL/Rails à forte charge d'IOPS vers Unicode sans interruption de service
Vous êtes un technicien travaillant pour l'une des nombreuses startups qui se sont précipitées sur le marché, où les fondateurs ont collé à la hâte un Des rails application avec des emballages de barres chocolatées et du papier aluminium. Une fois qu'il est devenu évident que l'enthousiasme ne pouvait pas remplacer la puissance brute du codage, des développeurs ont été embauchés pour masquer les trous dans l'architecture logicielle. Finalement, lorsque ceux les développeurs ont réalisé à quel point l'application était une bête sauvage, ils ont embauché toi pour nettoyer le désordre et rendre les choses plus jolies.
Vous connaissez votre pile. Vous avez une ancienne base de données MySQL, probablement MySQL 5.0 ou 5.1. Elle a été configurée avec des paramètres par défaut (lire : nous prenons en charge l'anglais) dès le premier jour, et le seul véritable changement (« avancée ») que quelqu'un a ajouté depuis lors est probablement un esclave de lecture et une réplication asynchrone. Après des années de fonctionnement continu dans ce mode, vos développeurs ont trouvé un millier de correctifs horribles et non maintenables pour permettre à certains caractères non ASCII d'être stockés dans les champs BLOB. Pendant ce temps, vos équipes de support se plaignent que la plupart des gens sur la planète reçoivent des erreurs en utilisant votre application avec leurs noms non romanisés, et la direction est agacée par le grand nombre de fonctions de translittération subtilement différentes dans le code.
C'était la situation à PagerDuty il y a plusieurs mois, et cet article explique comment nous avons résolu le problème – comment nous sommes passés de MySQL 5.1 stockage latin1 (ISO-8859-1) caractères vers le brillant MySQL 5.5 avec Unicode ( UTF-8 ) personnages… et comment vous ne l’avez jamais remarqué.
Le problème avec MySQL en quelques mots
Les jeux de caractères utilisés par MySQL lors de l'écriture de données sur le disque imposent certaines limitations à votre application. Un utilisateur naïf pourrait prétendre que MySQL n'a pas besoin de connaître quoi que ce soit sur les jeux de caractères, ce qui serait logique si vous vouliez de mauvaises performances lors du tri de vos chaînes ; vos CHAR et VARCHAR. vouloir Pour tirer parti de l'indexation de la base de données pour effectuer des tris implicites côté serveur (tri côté client en cours de traitement : veuillez mourir), MySQL doit comprendre les caractères que vous utilisez afin de disposer d'un contexte de tri qui ne soit pas simplement une valeur ordinale. Malheureusement, le jeu de caractères par défaut que MySQL comprend est latin1, ce qui exclut les symboles utilisés dans environ 90 % du monde. Un jeu de caractères Unicode comme UTF-8 est beaucoup plus approprié lorsque vous avez l'intention de stocker des chaînes de symboles multinationaux sans recourir à des BLOB.
Les jeux de caractères MySQL sont intégrés dans une colonne au moment de la création de la colonne. Bien sûr, MySQL vous permet depuis longtemps d'ALTER TABLE et de modifier cette propriété, ce qui facilite le passage d'un jeu de caractères à un autre, mais ALTER TABLE verrouille toute la table lors de son travail, ce qui n'est pas bon pour les applications en direct soumises à des écritures lourdes, où vos utilisateurs s'attendent à une réactivité continue. Il faut quelque chose d'un peu plus complexe. Voici l'histoire de ce quelque chose.
Avant de commencer, lisez les exigences
Chez PagerDuty, nous considérons ce défi comme un obstacle technique surmontable qui ne devrait pas avoir d'impact sur notre activité. À savoir:
- Tant que nous continuerons à utiliser MySQL, nous ne souhaitons plus jamais migrer les banques de données en raison de problèmes de stockage/d'entrée liés aux symboles (nous souhaitons accepter un ensemble de symboles universel).
- Ce changement devait avoir un impact au plus négligeable sur les performances continues de l'application PagerDuty (aucune nouvelle infrastructure cloud ne pouvait être mise en place à des fins de débit d'événements).
- Corollaire : aucune ressource de stockage significative ne doit être nouvellement allouée pour accueillir les caractères MySQL codés en UTF-8 (nous autorisons au plus 2 fois les anciennes exigences de stockage ; ce n'est pas déraisonnable étant donné que la plupart de nos utilisateurs utiliseront simplement des caractères romanisés, s'attendant à ce que toute autre solution échoue).
- L'ensemble de la procédure qui permet de mettre en œuvre cette solution devrait entraîner un temps d'arrêt négligeable (< 1 minute) pour nos utilisateurs.
Cela vous paraît ambitieux ? C'est le minimum de conditions qui nous ont été imposées et nous sommes heureux de dire que nous avons réussi à les remplir toutes.
MySQL – Dénouer une pléthore de folies
MySQL rend la conversion vers UTF-8 incroyablement pénible, afin d'essayer de dissimuler les limitations du InnoDB moteur. Nous commençons par discuter des problèmes avec les index sur les données CHAR/VARCHAR, en supposant que vous utilisez InnoDB (que nous avons utilisé, car au moins notre serveur ne venait pas du Âge de pierre ).
Saviez-vous qu'InnoDB a des limites basses sur la taille des index à colonne unique ? Nous non plus, mais nous avons découvert jusqu'où ces développeurs MySQL sournois ont essayé d'empêcher que cela ne vous porte préjudice, à vous, l'utilisateur imprudent. Vous voyez, l'encodage « utf8 » de MySQL 5.1 n'est pas du vrai UTF-8. UTF-8 prend en charge les symboles d'une longueur comprise entre 1 et 4 octets. L'utf8 de MySQL prend en charge uniquement les symboles d'une taille comprise entre 1 et 3 octets . Cela va à l'encontre de notre premier objectif : prendre en charge tous les personnages. Afin de résoudre que petit oubli, nous avons utilisé l'encodage « utf8mb4 » fourni dans MySQL 5.5 [1] … sauf que nous n'utilisions pas encore la version 5.5. Notre solution ce le problème nécessitait de retourner les serveurs de base de données (j'avais dit qu'il y aurait un peu de temps d'arrêt !) – mais nous y reviendrons.
Les tests initiaux de MySQL 5.5 ont été positifs jusqu'à ce que nous essayions de recréer nos tables de production via mysqldump [2] avec l'encodage UTF-8 à la place de latin1 :
mysqldump -d la_base_de_données | sed -e 's/(.*DEFAULT CHARSET=)latin1/1utf8mb4/' | mysql la_base_de_données_utf8
S'il vous plaît, ne nous frappez pas. Nous avons été surpris par cette étrange erreur :
ERREUR 1071 (42000) : la clé spécifiée était trop longue ; la longueur maximale de la clé est de 767 octets
Oh malheur à MySQL ! Pour avoir vu ce qu'il a vu, voyez ce qu'il voit ! En effet, si vous jetez un œil aux petits caractères, InnoDB ne prend en charge que les index à colonne unique d'une taille maximale de 767 octets . C'est peut-être pour cela que l'encodage « utf8 » ne prend en charge qu'un maximum de 3 octets par caractère : cela signifie que les conversions en utf8 à partir d'autres jeux de caractères fonctionnent lorsque des index de colonne sont impliqués. Considérez ceci : pour que le comparateur d'index soit rapide, toutes les entrées doivent avoir la même taille : la taille combinée maximale de la ou des colonnes sur lesquelles elles sont réparties. Avec un VARCHAR(255), un type de cellule assez standard, et l'encodage utf8 paralysé de MySQL, max_length_of_string * max_size_of_char s'étend à 255 * 3 => 765. Avec utf8mb4, 255 * 4 => 1020. Oups. Quelle galère.
Heureusement, le lien qui décrit cette limitation décrit également la solution de contournement (permettant à la taille de l'index d'atteindre un maximum de 3072 octets pour une seule colonne), ce qui conduit à certaines des lignes suivantes dans notre fichier /etc/my.cnf :
[client] default-character-set = utf8mb4 [mysqld] default-storage-engine = INNODB sql-mode='NO_ENGINE_SUBSTITUTION' # file_per_table est requis pour large_prefix innodb_file_per_table # file_format = Barracuda est requis pour large_prefix innodb_file_format = Barracuda # large_prefix donne des index max. sur une seule colonne de 3072 octets = win! # nous allons également définir ROW_FORMAT=DYNAMIC sur chaque table, cependant. innodb_large_prefix character-set-client-handshake = FAUX collation-server = utf8mb4_unicode_ci init-connect='DÉFINIR collation_connection = utf8mb4_unicode_ci' init-connect='DÉFINIR LES NOMS utf8mb4' character-set-server = utf8mb4 [mysqldump] default-character-set = utf8mb4 [mysql] default-character-set = utf8mb4
Nous sommes convaincus qu'il existe une manière plus concise d'obtenir ce que vous voulez de MySQL, mais comme le dit le vieil adage à propos de cette situation : « décoller, détruire le site depuis l'orbite… c'est le seul moyen d'être sûr. » Si vous démarrez un serveur avec ce fichier my.cnf et les instructions mysql_install_db, CREATE TABLE spécifiant ROW_FORMAT=DYNAMIQUE volonté faire la bonne chose , et vous fournit des chaînes CHAR/VARCHAR qui peuvent être indexées, tout en prenant en charge tous les symboles dont vous pourriez avoir besoin.
Il y a un problème quelque peu lié ici, à savoir que les index multi-colonnes sont également limités à un maximum de 3072 octets. Celui-ci pourrait être plus difficile à résoudre. Nous n'avions pas de solution astucieuse au problème : un seul index composite était affecté par celui-ci, et cet index s'exécutait sur une table avec peu de lignes (qui était donc MODIFIABLE). L'index s'exécutait sur la colonne phone_number qui était inutilement un VARCHAR(255), donc un ALTER TABLE rapide (enfin, son cousin abstrait, la « migration » Rails) s'est occupé de cela pour nous et l'a réduit.
Classements : apprenez à ne plus vous inquiéter et à aimer Unicode
Nous pensions que l'augmentation soudaine des index transformerait notre serveur MySQL en un mastodonte encombrant. Il s'est avéré que ce n'était pas le cas : le résultat net de notre migration a été très neutre, ou oscillant sur une petite marge. gain de vitesse ! Si vous exécutez une application Rails semi-moderne, il y a de fortes chances que cela soit également vrai pour vous. La raison ? Les classements.
Les classements indiquent à MySQL comment trier les chaînes de caractères afin qu'elles aient un sens. Intuitivement, la chaîne « abc » vient avant « bbc » dans un tri ascendant car le « a » initial précède alphabétiquement le « b ». Cependant, les caractères compliqués nécessitent des règles plus compliquées. Par exemple, le Institut allemand de normalisation (DIN) définit deux classements latin1 possibles ; le classement DIN-1 (dictionnaire allemand) définit le symbole « ß » comme équivalent à « s », et DIN-2 (annuaires téléphoniques allemands) définit ß = « ss » (entre autres différences). Une fois la réduction effectuée, un tri lexical standard (anglais) est utilisé.
Cela est important lorsque les connexions client veulent une requête ordonnée par un champ de chaîne, et vous avez donc besoin quelques façon de trier les chaînes. Les classements MySQL, s'ils sont utilisés correctement, vous donnent cette commande pratiquement gratuitement (tant que vous disposez d'un index sur les champs de chaîne). Une condition préalable souvent négligée pour bénéficier de cet avantage est que le client et le serveur doivent utiliser le même jeu de caractères et le même classement. Il s'avère que jusqu'à notre migration de base de données UTF-8, ce n'était pas le cas chez PagerDuty.
Considérez votre application Rails. Il y a de fortes chances que vous utilisiez soit l' MySQL/Ruby ou la mysql2 gem pour alimenter ActiveRecord. Ils lisent à partir d'un fichier database.yml qui spécifie quoi comme type d'encodage ? Oh, utf8 ? Si vous fouillez un peu dans le code gemme (ce que nous avons fini par faire avoir à faire ), vous remarquerez que cet encodage est transmis aux paramètres de connexion MySQL ; il devient le jeu de caractères (et définit le classement) utilisé pour communiquer avec MySQL. Le fait que vous le définissiez sur utf8 tout en communiquant avec une base de données basée sur latin1 est le cœur de l'accélération que vous êtes sur le point de réaliser.
Fait : pendant tout ce temps, vous avez gaspillé des cycles CPU sur le tri. MySQL a fait abstraction de cela et vous a fourni des chaînes triées dans un ordre que votre application cliente (Rails) comprenait, tout en devant maintenir un mappage entre un classement UTF-8 (probablement utf8_general_ci) et le classement auquel vos tables ont été liées. Vous ne me croyez pas ? Regardez ce qui se passe lorsque vous configurez le client et le serveur pour qu'ils s'exécutent tous les deux en utf8mb4 avec le même classement (nous avons choisi utf8mb4_unicode_ci ; voir ici pour une discussion sur les différences de classement Unicode dans MySQL). Profitez de l'accélération. Remerciez-moi plus tard.
Maintenez vos données en vie : migrez, répliquez et mettez à niveau
Il nous a fallu tout ce texte, mais nous arrivons enfin à la partie délicate : comment allez-vous migrer votre ancienne base de données alimentée au charbon ? Vous avez déjà vu un hack astucieux utilisant mysqldump + sed pour charger des données sur un nouveau serveur. Mais votre ancienne base de données est toujours en cours d'écriture – alors maintenant que faire ? La solution ici consiste à ce que MySQL vous lance un os avec cette insistance à connaître et à séparer les jeux de caractères client/serveur.
Nous aimerions voir comment cela fonctionne, mais malheureusement, je n'ai pas pu prendre le temps de lire le code source de MySQL ou de trouver des informations crédibles à ce sujet. En utilisant le fichier de configuration ci-dessus pour notre nouveau serveur, la simple configuration de la réplication maître/esclave entre notre ancienne base de données et la nouvelle base de données 5.5 UTF-8 a fonctionné parfaitement. Nous avons testé toutes sortes de caractères latin1 insérés dans l'ancienne base de données, et ils sont revenus sans problème dans la copie répliquée. MySQL effectuait toutes les traductions correctes, et nous n'avions plus qu'à nous asseoir et à regarder. Une fois l'effet hypnotique dissipé, il était temps de faire du travail, à savoir que tous nos serveurs Web devaient avoir leur client mysql paquets mis à jour. Comme vous pouvez le constater, mysql-client 5.1 ne parle pas utf8mb4 et aura également des problèmes pour communiquer avec votre serveur 5.5.
Pour ce faire, nous avons utilisé chef pour lancer rapidement de nouveaux serveurs backend d'applications - des clones de nos serveurs existants - mais avec la nouvelle version du client mysql, et configurés de manière à ce que les travailleurs en arrière-plan (toutes les tâches de traitement de file d'attente et asynchrones de PagerDuty) soient désactivés. Les merveilleux fruits du cloud - parfois utiles ! Ces serveurs étaient pointés vers la base de données esclave et étaient déjà configurés (via les paramètres d'environnement chef, qui ont beaucoup plus de sens pour ceux qui utilisent chef) pour prendre entièrement en charge utf8mb4 jusqu'à Rails via mysql2. Avec ces mauvais garçons prêts (et quelques tests pour nous assurer qu'ils fonctionnaient comme nos environnements de test), nous étions prêts à retourner notre base de données.
Fais-le, fais-le maintenant ! Allez, retourne-moi !
Le processus de retournement est ce moment incroyablement risqué où vous n'êtes tout simplement pas sûr que tout fonctionnera ou si vous avez oublié un détail crucial, et vos clients sont sur le point d'être très mécontents. Vous parcourez nerveusement votre liste de contrôle, en vous assurant de souligner le moment critique de non-retour. Dans notre retournement, nous avions les éléments suivants :
- fermer les travailleurs d'arrière-plan actuels sur les anciens backends d'application
- (à ce stade, nous ne traitons plus l'envoi de notifications, mais nous mettons toujours les demandes en file d'attente)
- verrouiller la base de données principale
- (à ce stade, nous sommes complètement arrêtés – c'est le temps d'arrêt dont vous avez été averti ! Les nouvelles demandes sont gelées)
- arrêter et réinitialiser l'esclave
- exécuter Chef sur nos équilibreurs de charge orientés client, en les intégrant dans le nouvel environnement Chef et en les modifiant pour utiliser nos machines nouvellement lancées comme backends d'application
- (à ce stade, nous acceptons à nouveau les demandes, les demandes de pré-retournement expireront)
- faire tourner les travailleurs en arrière-plan sur les nouveaux backends d'application
- (à ce stade, nous sommes pleinement fonctionnels)
- mettre fin aux anciens backends d'application
Comme vous pouvez l'imaginer puisque je vous en parle maintenant, tout cela s'est exécuté sans erreur. Vous avez lu dans un autre post comment fonctionnent nos processus d'arrière-plan et à quel point il est facile de créer des scripts surveiller , notamment en collaboration avec le chef, pour arrêter et relancer nos tâches en arrière-plan. Chef-client Les exécutions sur nos équilibreurs de charge se terminent généralement en 20 secondes grâce au travail acharné de notre équipe d'exploitation. Nous savions donc que ce serait la limite supérieure de notre temps d'arrêt. Les seules commandes SQL que nous avons dû exécuter pour faire avancer les choses étaient :
(sur le maître)
DÉBUT; RINÇAGE DES TABLES AVEC VERROUILLAGE EN LECTURE ;
(sur l'esclave, une fois que vous avez vérifié qu'il a rattrapé le maître)
ARRÊTER L'ESCLAVE ; RÉINITIALISER L'ESCLAVE ;
Et voilà. Le stress avait disparu et vous, le client, avez à peine remarqué que nous ignorions temporairement vos événements. Il ne restait plus qu'à reconfigurer tranquillement nos esclaves et nos sauvegardes pour cibler un nouveau serveur MySQL. Oh, et Rails avait besoin d'un peu d'amour ; voici ce que nous avons ajouté au fichier config/initializers/activerecord_ext.rb de notre application :
module ActiveRecord module ConnectionAdapters module SchemaStatements def create_table_with_dynamic_row_format(table_name, options = {}, &block) new_options = options.dup new_options[:options] ||= '' new_options[:options] << ' DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC' create_table_without_dynamic_row_format(table_name, new_options, &block) end alias_method_chain :create_table, :dynamic_row_format end end end
Autopsie
Si vous êtes arrivé jusqu'ici, félicitations cher lecteur – vous êtes dévoué. J'espère que cet article vous inspirera à faire le bien et à effacer les taches d'années d'applications principalement en latin1 ton entreprise. Donnez à ce moteur une bonne révision. Mais attention… dans le monde technologiquement mal implémenté d'Unicode, l'excitation ne s'arrête jamais. Il y a toujours plus de composants à intégrer dans le 21 St Mélange de langues du 19e siècle.
[1] Si vous vous opposez Unification des Han , alors après tous les efforts ici, nous ne soutenons malheureusement toujours pas vos personnages éclectiques.
[2] Votre mysqldump doit être configuré pour utiliser le jeu de caractères utf8 (un sur-ensemble strict de latin1) pour générer vos fichiers texte, sinon vous risquez de voir un tas de charabia inséré dans votre nouvelle base de données.