Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

MySQLの0000-00-00 00:00:00という負債とridgepoleの限界

こんにちは、CTOの島田です。 今回は、StudyplusのDBのmigrationで発生した問題とその解決ステップを説明したいと思います。

前提

まずは前提。

  • Aurora MySQL 5.7
  • Rails 5.1.6 (対応当時。今は5.2.2)

schema.rbでの運用

Studyplus本体のmigrationは、色々な経緯によって2018年5月まで、いわゆるRailsのmigration の作法とは異なる方法で運用されてました。

schema.rb でスキーマの状態を管理してはいたのですが通常とはやや異なる管理がされていました。 以下、改変して一部抜粋。

ActiveRecord::Schema.define do
  execute %q(
    CREATE TABLE `samples` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `content_type` varchar(256) DEFAULT NULL,
      `last_modified_at` datetime DEFAULT NULL,
      `issued_at` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
      PRIMARY KEY (`id`),
      UNIQUE KEY `content_type ` (`content_type `)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  )
...
end

このように execute で直接DDLが記述されていました。 開発環境を構築する場合や自動テストを実行するにはこれで良いですが、カラムやインデックスの追加、削除などのALTER TABLE に対してはこれでは対応できません。
そういった変更を各環境に適用するためにはDDLを直接DBで実行するという属人的なオペレーションが必要になっていました。

ridgepoleの導入

そこでschema.rbでの運用による解決として、ridgepole の導入を決めました。
これで煩雑かつ属人的でリスクの高いスキーマ変更作業から解き放たれるかと思ったのですが、別の問題が勃発しました。

github.com

"0000-00-00 00:00:00" 問題

ridgepoleを導入してSchemafileを生成し、以下のようにdry-runを実行するとなぜか差分が発生してしまいました。

以下、改変して一部抜粋。

$ ridgepole -c ./config/ridgepole.yml -f ./db/Schemafile --apply --dry-run
Apply `./db/Schemafile` (dry-run)
change_column("samples", "issued_at", :datetime, {:unsigned=>false, :comment=>nil})
...

# ALTER TABLE `samples ` CHANGE `issued_at` `issued_at` datetime NOT NULL
...

このようなカラムが、なんと60テーブルに及び90カラム以上!

問題の原因は?

差分として change_column... となってしまうカラムには共通点がありました。
それは、全て DATETIME でDEFAULT '0000-00-00 00:00:00'でした。

よくよくSchemafileを確認してみると、確かに

create_table "samples", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
...
  t.datetime "issued_at", null: false
...
end

となっていました。

試しに、

t.datetime "issued_at", null: false

t.datetime "issued_at", null: false, default: '0000-00-00 00:00:00'

とすると、その部分のchange_column... は出力されなくなりました。

ここからは推測ですが、ridgepoleではActiveRecordを利用しているため、Schemafileをexportする際に '0000-00-00 00:00:00'を認識することが出来ないのではないかと考えました。

例えば以下のようなデータがあった場合に、

mysql> SELECT  `samples`.* FROM `samples` ORDER BY `samples`.`id` ASC LIMIT 1 \G
*************************** 1. row ***************************
                 id: 1
...
          issued_at: 0000-00-00 00:00:00
1 row in set (0.00 sec)

ActiveRecord では、

[1] pry(main)> sample = Sample.first
...
=> #<Sample:0x000055b595171600
 id: 1,
...
 issued_at: nil>

というように、nilと認識をされてしまう事と関連があるのではないかと思いました。

これはridgepoleやActiveRecordの不具合でなく、0000-00-00 00:00:00を利用している事が問題であり、0000-00-00 00:00:00の撲滅をしてく事を決めました。

0000-00-00 00:00:00がなぜ良くないかについては、そーだいさんの MySQLの0000-00-00 00:00:00は使ってはならない の記事を参照してもらえればと思います。

soudai.hatenablog.com

暫定対応

ただ撲滅をするといっても数も多く、すぐに全てを対応するのは難しい状態でした。
しかし、スキーマ変更でDDLの直接実行をするのを防ぐ事を優先したかったので、やむえず暫定オペレーションを考えることにしました。

それは、$ ridgepole --export -o Schemafile で生成したSchemafileに default: '0000-00-00 00:00:00' を追加するRakeタスクを作るという苦肉の策でした。

Rakeタスクの内容としては、

  1. ridgepole で Schemafile を export(ファイル名: SchemafileImport)
  2. Rakeタスクにてdefault: '0000-00-00 00:00:00'を追加する対象の列に、defaultを追加して、Schemafileを別途出力(ファイル名:SchemafileExport) という処理になります。

そして、スキーマ変更をする順番は、

  1. $ rake database_export:with_default_datetime
  2. ridgepole の実行にはSchemafileExportを利用する
    • $ ridgepole -c ./config/ridgepole.yml -f ./db/SchemafileExport --apply

という手順となっていました。

以下、Rakeタスクを一部改変して掲載

namespace :database_export do
  desc "Execute ridgepole export"
  task :with_default_datetime do
    # ridgepoleで生成するSchemafile
    IMPORT_OUTPUT_PATH = "./db/SchemafileImport".freeze

    # スキーマ変更時に利用するSchemafile
    EXPORT_OUTPUT_PATH = "./db/SchemafileExport".freeze


    OVERWRITE_TARGETS = {
      samples: ["issued_at", "xxxx_at"],
      xxxs: ["xxxx_at"],
       ...
    }.freeze

    @updated_schema = ""

    puts "exporting schema"
    sh "ridgepole -c ./config/ridgepole.yml --export -o #{EXPORT_OUTPUT_PATH}"
    sh "ridgepole -c ./config/ridgepole.yml --export -o #{IMPORT_OUTPUT_PATH}"

    puts "adding default value to EXPORT file"
    File.open(EXPORT_OUTPUT_PATH, "r") do |file|
      file.each_line("") do |lines|
        line_array = lines.split("\n")
        create_table_line = ""
        create_table_line = line_array[2] if line_array[0]&.match(/^\#/) && line_array[2]&.match(/^create_table/) # 1個目のテーブル用
        create_table_line = line_array[0] if line_array[0]&.match(/^create_table/)
        if create_table_line
          create_table_line_array = create_table_line.split(/\s/)
          table_name = create_table_line_array[1]&.match(/[a-zA-Z\_]+/).to_s
          if OVERWRITE_TARGETS.keys.include?(table_name.to_sym)
            line_array.each do |line|
              col_array = line.split(/\s/)
              col = col_array[3]&.match(/[a-zA-Z\_]+/).to_s
              if OVERWRITE_TARGETS[table_name.to_sym].include?(col)
                lines.gsub!(/#{line}/, line + ", default: '0000-00-00 00:00:00'")
              end
            end
            @updated_schema += lines
          else
            @updated_schema += lines
          end
        end
      end
    end
    puts "updating file"
    File.open(EXPORT_OUTPUT_PATH, "w") do |file|
      file.puts(@updated_schema)
    end
    puts "update finished"
  end
end

問題の解消

しばらくは、このRakeタスクでSchemafileを生成する運用と並行して、0000-00-00 00:00:00を廃止するという地道な作業をしていきました。 ただ、幸運にもほとんとの対象が、created_atupdated_atだったので(なぜ0000-00-00 00:00:00だったのか謎だが)

  t.datetime "created_at", null: false, default: '0000-00-00 00:00:00'
  t.datetime "updated_at", null: false, default: '0000-00-00 00:00:00'

  t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
  t.datetime "updated_at", default: -> { "CURRENT_TIMESTAMP" }, null: false

のように CURRENT_TIMESTAMPにしていくことで対応できました。

解消手順としては、

  1. 対象カラムを利用している箇所をCURRENT_TIMESTAMPにして問題ないか確認、必要に応じて修正
  2. 修正したコードをデプロイ
  3. Rakeタスクを変更
  4. ridgepoleにてALTER TABLEDEFAULTを修正

という事をひたすら繰り返しました。

あるべき姿

そうして、少しづつ進めてどうにか以下のようにする事ができました。

$ ridgepole -c ./config/ridgepole.yml -f ./db/Schemafile --apply --dry-run
Apply `./db/Schemafile` (dry-run)
No change

最後の対応が終わった時のSlackの投稿

まとめ

Studyplusはリリースから、2019年で7年目を迎えます。
最初はRailsのバージョンが3.0.9でしたが、現在は5.2.2となっており、これまで様々なコントリビューターの方が関わってきました。
そのため、サービスの運用を続けていれば必然的に発生する技術的な課題が弊社にも少なからずあります。
しかし、それらを放置する事なく地道に改善を続けて、モダンな仕組みを取り入れています。
もし、そんなスタディプラスのサーバーサイドの開発に興味がある方は、是非 こちら から応募いただければと思います。
(または、私のTwitter にDMをいただくでも構いません)

info.studyplus.co.jp