Von Klein nach Groß - Der Umstieg auf Bigint-Primary-Keys in Rails
Rails verwendet schon seit einiger Zeit 8 Byte Primary-Keys und der Umstieg läuft nicht immer schmerzfrei. Wir teilen einige nützliche Hinweise für die Umstellung.
Der Ein oder Andere kennt das vielleicht: Es gibt eine kleine, interne App, die ihre Dienste seit vielen Jahren gut verrichtet und das auch weiterhin tut. Irgendwann gibt es dann aber doch mal einen Feature-Request und dann stellt man fest, dass der Tech-Stack der Anwendung ordentlich in die Jahre gekommen ist. Dies war der Fall bei einer unserer Rails-Anwendungen. Um die App auf den aktuellen Stand des Frameworks zu bekommen – inklusive Standards und Tools – und den Datenbestand fortführen zu können, haben wir uns für eine Re-Implementierung der Anwendung auf Basis des alten Datenmodells entschieden.
Das bestehende schema.rb
diente daher als Grundlage für eine erste Migration in der neuen Anwendung.
Beim Aufsetzen einer neuen Test- oder Stagingumgebung mit einer MySQL-Datenbank stolpert man aber schnell über folgenden Fehler:
Column `<model>_id` on table `<model_2>` does not match column `id` on `<model>`, which has type `bigint(20)`. To resolve this issue, change the type of the `<model>_id` column on `<model_2>` to be :bigint. (For example `t.bigint :<model>_id`).
Original message: Mysql2::Error: Can't create table ... (errno: 150 "Foreign key constraint is incorrectly formed")
Denn Einer der neuen Standards für Rails-Applications seit Rails 5.1 sind größere Primary-Keys. Die Schlüssel sind nun bigint(20)
, statt int(11)
wie bisher.
Eine Möglichkeit den Fehler zu beheben, ist den Datentyp der Primary-Keys wieder auf int
zu setzen, z.B. in einem Initializer mit:
ActiveRecord::ConnectionAdapters::Mysql2Adapter::NATIVE_DATABASE_TYPES[:primary_key] = "int(11) auto_increment PRIMARY KEY"
Aber auch hier hängen Syntax und Verfügbarkeit von Änderungen in ActiveRecord
ab und ein Grund für die Re-Implentierung war schließlich der Umstieg auf neue Standards.
Daher zeigen wir hier unseren Ansatz auf, der:
- eine neue Anwendung mit den korrekten Datentypen aufsetzt
- sowie die Datentypen aus der Legacy-App korrekt anpasst
Dafür passen wir zunächst die Migration für unser initiales Datenmodell an. Um mit einer leeren Datenbank, sowie den bestehenden Daten umgehen zu können, prüfen wir ob eine Tabelle bereits existiert. Besteht die Tabelle noch nicht, passen wir lediglich den Typ der Foreign-Key-Referenzen an:
unless table_exists?('vacations')
create_table 'vacations', force: :cascade do |t|
- t.int 'employee_id', default: 0, null: false
+ t.bigint 'employee_id', default: 0, null: false
t.date 'start_date', null: false
...
t.integer 'lock_version', default: 0, null: false
end
end
Wenn die Tabelle bereits existiert, müssen wir den Typ des Primary-Key, sowie aller Foreign-Key-Referenzen anpassen:
unless table_exists?('vacations')
create_table 'vacations', force: :cascade do |t|
...
end
else
#Anpassen des Datentyps für Primary-Key und alle Foreign-Key-Refs
change_column :vacations, :id, :bigint
change_column :vacations, :employee_id, :bigint
end
Dies reicht jedoch noch nicht ganz aus! Wenn ein Fremdschlüssel für eine Spalte besteht, darf man deren Datentyp auch nicht ändern. Um die Spalten anpassen zu können, müssen wir daher Foreign-Keys zuerst entfernt und später wieder angelegen. Insgesamt sieht unsere Migration für das Datenmodell in etwa so aus:
#alle Foreign-Keys entfernen
if foreign_key_exists?("model", "model2")
remove_foreign_key("model", "model2")
end
...
#alle Tabellen anpassen
unless table_exists?('model')
create_table 'model', force: :cascade do |t|
...
end
else
change_column :model, :id, :bigint
change_column :model, :model2_id, :bigint
...
end
...
# alle Foreign-Keys wieder anlegen
add_foreign_key "model", "model2", name: "fk_name"
...
Wenn man nur den bestehenden Datenbestand patchen möchte, ist das Vorgehen ähnlich. Eine Möglichkeit ist das Anlegen einer neue Migration pro Model, die einen ähnlichen Ablauf hat:
- Entfernen aller relevanten Foreign-Keys
- Ändern des Primary-Keys für das Model
- Ändern aller Foreign-Key-Referenzen für das Model in anderen Tabellen
- Erneutes Anlegen der Foreign-Keys
Die obigen Ausführungen bezogen sich auf MySQL, sollten für PostgreSQL aber identisch sein. Für SQLite ist dieser Prozess eigentlich unnötig, da es dort ohnehin nur einen Integer-Datentyp gibt (mit bis zu 8 Byte Länge).
Abschließend noch einige Hinweise für den Umstieg in Produktion:
- Da man an den Datentypen wichtiger Spalten Änderungen vornimmt, sollte man diese auf einer Staging Environment testen.
- Vor dem Ausrollen der Änderungen in Produktion ein aktuelles Backup der Datenbank(en)nicht vergessen!!!
- Änderungen an den Primary-Keys können bei größeren Datenmengen auch einiges an Zeit in Anspruch nehmen, daher sollte Downtime auch sinnvoll eingeplant werden.