Migrando de MySQL a Heroku Postgres

Felipe Domínguez
Código Banana: El blog de Platanus
5 min readApr 9, 2019

En este post escribí los pasos básicos para migrar desde una base de datos externa a Heroku Postgres, junto con los errores comunes y tips que me sirvieron para facilitar un poco el proceso.

Durante los últimos días estuve trabajando con ayuda de Juan Ignacio Donoso en migrar la plataforma Amigo Secreto de MySQL a Postgres. Esta es una aplicación escrita en Rails, creada por Platanus el 2012. En ella puedes jugar Secret Santa con tus amigos.

Durante diciembre la plataforma llegó a tener más de 2.5 millones de usuarios jugando, y de todas partes del mundo! (como Mozambique e Islandia 🌏). Cada año se crean más y más sorteos, con un crecimiento constante del 35~%, por lo que los requisitos de parte de los servidores han ido cambiando.

Para aprovechar los beneficios que ofrece Heroku (dataclips, rollbacks, etc…), decidimos migrar la base de datos desde MySQL en AWS RDS a Heroku Postgres. Esto además nos permite encapsular el pago y manejo de servidores en sólo una plataforma.

Esta es una lista de los cambios mínimos que tuve que hacer:

  • Eliminar la gema mysql2
  • Agregar la gema pg. En este caso usé la versión 0.21.0, es la última que soporta Rails 4.2.x
  • Editar el archivo config/database.yml para conectarse a Postgres. En este caso, teníamos como encoding: UTF-8mb4, que es propio de MySQL, en Postgres el equivalente es UTF-8 (más adelante explicaré esto).
  • Ejecutar rake db:schema:dump para que el db/schema.rb se actualice al formato de Postgres.

Importar y probar

Probando localmente

Para asegurar que todo funcionara correctamente, mi primer paso fue intentar correr la aplicación usando mi servidor local de PostreSQL con los datos ya importados. Para esto primero descargué un dump de la base de datos de production (7GB aprox) usando SequelPro, y con la herramienta PgLoader, intenté pasarlo a Postgres corriendo el comando:

 pgloader mysql://user:pass@host/db postgresql://user:pass@host/db

Este proceso tomaba unos 100 minutos 🐢… Y obvio, falló. El problema estuvo en incompatibilidades de Postgres con algunos datos:

  • Atributos created_at en formato 0000–00–00: Postgres la interpreta como inválida, ya que no existe el año 0. Lo que hice fue ponerles un valor aproximado según objetos con ID’s cercanos.
  • Atributos not null vacíos: Habían algunas fechas que estaban vacías. Misma solución que antes.👍🏻

Cuando había un conflicto, el proceso se caía. Tenía que arreglar el problema en el dump descargado (en MySQL) y volver a intentar. Tuve que repetir todo 3 veces… unas 5 horas!!

Para evitarme este problema a la hora de subir todo a producción, primero me aseguré que los cambios que realicé no rompieran nada en mi versión local. Luego decidí arreglar los datos directamente en la base de datos de producción ejecutando los mismos cambios.

UTF-8mb4 y emojis

Para soportar emojis en una base de datos, necesitamos que el encoding sea de 4 bytes por caracter. La versión “UTF-8” de MySQL en cambio usa sólo 3 bytes 🤨. Por lo que el 2010, liberaron el conocido “UTF-8mb4”.

En mi caso, la plataforma usaba UTF-8mb4. Para esto, todas las migraciones que creaban alguna columna string debían restringir el largo máximo:

# my migrationclass AddNametoUser < ActiveRecord::Migration
def change
add_column :users, :name, :string, limit: 191
end
end

Esto además provoca que el db/schema.rb se vea de la siguiente manera:

create_table "users", force: :cascade do |t|
t.string "name", limit: 191, null: false
...
...

Con el cambio de base de datos, ya no era necesario tener todos los strings restringidos, si no que podían volver a ser de largo 255, como lo es por defecto. Para esto, creé la siguiente migración:

# new migrationclass ChangeStringSize < ActiveRecord::Migration
def change
change_column :users, :name, :string, length: 255
change_column :users, :last_name, :string, length: 255
...
...
end
end

Después de ejecutar la migración, ahora el db/schema.rb se ve más limpio:

create_table "users", force: :cascade do |t|
t.string "name", null: false
...
...

Lidiando con conflictos de Postgres

Aquí empiezan a salir los detalles de la magia de PgLoader. Para no intervenir con la base de datos existente al importar el dump, PgLoader lo almacena bajo el schema db_name en lugar de public. Esto provoca que Rails no pueda leer ninguna tabla:

Después de importar:

        List of relations
Schema | Name | Type | Owner
— — — - + — —— - + — — — + — — -
db_name | table1 | table | me
db_name | table2 | table | me
db_name | table3 | table | me

Fix:

ALTER SCHEMA db_name RENAME TO public;

Final:

        List of relations
Schema | Name | Type | Owner
— — — - + — —— - + — — — + — — -
public | table1 | table | me
public | table2 | table | me
public | table3 | table | me

En mi caso, hice un db:migrate antes del fix, por lo que Rails no tuvo acceso a la tabla schema_migrations, y corrió todas las migraciones creando nuevamente las tablas (vacías), bajo el schema public. Por lo tanto habían dos versiones de la base de datos, una bajo el schema public, y otra en db_name. Para arreglar el problema tuve que correr lo siguiente:

DROP SCHEMA public CASCADE;
ALTER SCHEMA db_name RENAME TO public;

Una vez que todo estuvo andando localmente, pude empezar a testear la aplicación. Suerte aquí la mía, el único problema que tuve fue con el siguiente test:

ids = Comment.unflagged_for_user(user).map(&:id)# Falla
expect(ids).to eq([@c1.id, @c3.id])
# Fix
expect(ids).to contain_exactly(@c1.id, @c3.id)

Por alguna razón, las bases de datos no siempre retornar los elementos en el mismo orden. Por eso es siempre preferible usar contain_exactly.

Importando a Heroku

Para no jugar con fuego, primero cargué el dump en staging y así probar que que todo salía bien. Los pasos a seguir fueron los mismos que en local, pero ahora me ahorré tener que repetir el proceso de encontrarme con datos conflictivos 🙌.

Luego de unos días testeando la aplicación, finalmente hice la migración a producción.

Usando un .load file

Para simplificar un poco el proceso de importar con PgLoader, usé un .load file. Esto permite personalizar el proceso de conversión de los datos. En la documentación hay un ejemplo bastante explicativo útil.

Heroku obliga a conectarse con SSL si queremos acceder desde fuera, por lo que hay que agregarlo al url. Aquí está mi .load :

LOAD DATABASE
FROM mysql://user:pass@host/db
INTO postgresql://user:pass@host/db?sslmode=require
ALTER schema 'amigosecreto' rename to 'public'
CAST
type bigint to bigint drop typemod;

Luego bastó con correr:

pgloader my_file.load

Para terminar

Haber participado en esta migración fue un gran aprendizaje. En un principio se veía una tarea fácil, pero de a poco fueron surgiendo problemas que no esperaba.

Investigado en profundidad porqué ocurrían incompatibilidades entre MySQL y Postgres me permitió aprender acerca de los distintos tipos de encoding, y como las bases de datos lo implementan de maneras distintas.

Como opinión personal, realizar tareas distintas de vez en cuando ayuda a mantenerse entretenido en el día. Nunca había trabajado en una tarea como esta, y terminó siendo un desafío personal poder terminarla bien!

Referencias

  1. https://devcenter.heroku.com/articles/heroku-mysql
  2. https://developer.salesforce.com/blogs/developer-relations/2016/05/mysql-to-postgres-in-20-minutes.html
  3. https://medium.com/@nathanwillson/converting-from-mysql-to-postgres-with-pgloader-for-heroku-b1212c6ad932
  4. https://medium.com/@adamhooper/in-mysql-never-use-utf8-use-utf8mb4-11761243e434
  5. https://blog.arkency.com/2015/05/how-to-store-emoji-in-a-rails-app-with-a-mysql-database/

--

--