RubyのFloatクラスとMySQLのFLOAT型の違いにハマった話

こんにちは!SWEの高橋(@thehighhigh)です。 この記事は ANDPAD Advent Calendar 2024の 10日目の記事です。

私は入社して以降「ANDPAD図面」のサーバーサイドの開発に携わっており、最近は新規機能の開発を進めています。

そんな「ANDPAD図面」のサーバーサイドは、主にRuby on Railsで構築されています。本記事では、Ruby on Railsで浮動小数点数を扱う際にハマった問題についてお話ししようと思います。

※今回の動作環境は以下のとおりです

Rails 7.2.2
MySQL: 8.0.28

ハマった問題:浮動小数点数によって、意図せずテストが失敗

例示のため、以下のようなexperiment_resultsテーブルと、ExperimentResultsControllerを定義します。 何かの実験を行ってその結果(浮動小数点数)をmeasured_value(測定値)に格納するイメージです。

このとき、measured_valueの型はFLOATで定義しています。

ActiveRecord::Schema[7.2].define(version: 0) do

  ...

  create_table "experiment_results", force: :cascade do |t|
    t.float "measured_value", comment: "実験での測定値"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

  ...

end
class ExperimentResultsController < ApplicationController

  ...

  def create
    @experiment_result = ExperimentResult.new(create_experiments_params)

    if @experiment_result.save
      render json: { id: @experiment_result.id, measured_value: @experiment_result.measured_value }, status: 201
    else
      render json: { errors: @experiment_result.errors.full_messages }, status: :unprocessable_entity
    end
  end

  ...

  private

  def create_experiments_params
    params.require(:experiment_result).permit(:measured_value)
  end
end

これに対して、RSpecで以下のようなテストコードを書きます

require 'rails_helper'

RSpec.describe ExperimentResultsController do
  describe 'POST #experiment_results' do
    subject { post experiment_results_url, params: params, as: :json }

    context 'with valid parameters' do
      let(:params) { { experiment_result: { measured_value: 9998.999 } } }

      it 'creates a new experiment result' do
        expect { subject }.to change { ExperimentResult.count }.by(1)
        expect(response).to have_http_status(200)
        # 保存された値が正しいか確認
        expect(ExperimentResult.find(response.parsed_body['id']).measured_value).to eq(9998.999)
      end
    end
  end
end

一見すると成功しそうですが、実際にはこのテストは失敗してしまいます。

Failures:

  1) ExperimentResultsController POST #experiment_results with valid parameters creates a new experiment result
     Failure/Error: expect(ExperimentResult.find(response.parsed_body['id']).measured_value).to eq(9998.999)

       expected: 9998.999
            got: 9999.0

       (compared using ==)

measured_valueの精度が保たれず、意図せずに丸められてしまっていることが原因のようです。 では一体なぜこのテストが落ちてしまうのか、詳しく調べてみることにしました。

原因はMySQL?

この問題のコードで色々と試してみると、以下のことがわかります。

  • 桁数を減らして、9998.999ではなく、998.999に変更すると、テストが成功する
  • 桁数を増やすと、小数点を含んだどの数値でもテストが失敗する
  • createdと共に送られるレスポンスは9998.999のまま精度を保っている
    • つまり、DBを介さない場合、精度は保たれている

これらのことから、Ruby on Rails側ではなく、データを保存した後のMySQL側の浮動小数点数の精度に原因がある可能性が高いと考えました。

MySQLのFLOAT型とRubyのFloatクラスの違い

原因が絞れたので、MySQLのFLOAT型と、RubyのFloatクラスについてそれぞれ調べてみました。

MySQLのFLOAT型

MySQLの公式ドキュメントには以下のように書かれています。

FLOAT および DOUBLE 型は概数値データ値を表します。 MySQL は、単精度値には 4 バイトを、倍精度値には 8 バイトを使用します。

つまり、FLOATでは、文字通りC言語のfloatと同様の4バイト=32ビットの単精度浮動小数点数を使用していることがわかります。

この32ビットの単精度浮動小数点数の精度がどれくらいかというと、一般には7桁の精度といわれています。※1

つまり9998.999のような7桁の値は、保存時に丸められることがある、ということがわかります。

RubyのFloatクラス

一方で、Rubyの公式ドキュメントを見てみると、Floatクラスについて以下のように書かれていました。

浮動小数点数のクラス。Float の実装は C 言語の double で、その精度は環境に依存します。 一般にはせいぜい15桁です

つまり、一見するとC言語のfloatを連想させる命名ですが、実際にはdoubleとして扱うべきということです。

原因はこの精度の差

これらのことから、同じ名がついているものの、MySQLのFLOAT型とRubyのFloatクラスの精度が異なっていることが原因で、データの意図しない誤差が発生したということがわかりました。

対応方法

Ruby側がC言語のdoubleに対応しているので、MySQLで同様にDouble型カラムに変更すれば精度は保たれるため、この問題が解消しそうということがわかります。

ここでも少しハマったポイントがあったので、対応方法についてもご紹介します。

Ruby on Railsでは、MySQLのDOUBLE型を直接定義できない

実はRuby on RailsのActiveRecord::Migrationでは、カラムにDOUBLE型を直接定義できません。

これは、Ruby on Railsの公式ドキュメントのActiveRecord::ConnectionAdapters::SchemaStatementsadd_columnの記述から確認できます。

The type parameter is normally one of the migrations native types, which is one of the following: :primary_key, :string, :text, :integer, :bigint, :float, :decimal, :numeric, :datetime, :time, :date, :binary, :blob, :boolean.

ただし、以下のようにfloatに対して24~53のlimitを指定することで、DOUBLE型として定義されます。

ActiveRecord::Schema[7.2].define(version: 0) do

  ...

  create_table "experiment_results", force: :cascade do |t|
    t.float "measured_value", limit: 53, null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

  ...
end

mysqlを直接見に行くと、きちんとDOUBLE型になっていることがわかります。

mysql> DESCRIBE experiment_results;
+----------------+-------------+------+-----+---------+----------------+
| Field          | Type        | Null | Key | Default | Extra          |
+----------------+-------------+------+-----+---------+----------------+
| id             | int         | NO   | PRI | NULL    | auto_increment |
| measured_value | double      | NO   |     | NULL    |                |
| created_at     | datetime(6) | NO   |     | NULL    |                |
| updated_at     | datetime(6) | NO   |     | NULL    |                |
+----------------+-------------+------+-----+---------+----------------+
4 rows in set (0.02 sec)

この状態で、先のテストを再度実行してみると、、、

Finished in 13.07 seconds (files took 16.29 seconds to load)
1 example, 0 failures

見事にパスしました!これで一件落着です。

結論

今回の問題から、以下のことがわかりました

  • Ruby on Rails と MySQLの組み合わせで浮動小数点を扱うときは、精度の違いに注意
    • MySQLのFLOAT型の精度は、C言語のfloatと同等の精度
    • RubyのFloatの精度は、C言語のdoubleと同等の精度
  • Ruby on Railsで、MySQLのDOUBLE型を扱うには、floatlimitを指定する

今後Ruby on Rails上で浮動小数点数を扱う際には、こういった仕様の差異を意識したいと思いました。

最後に

アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を募集しています。

ご興味を持たれた方は、下記のサイトをぜひご覧ください。

engineer.andpad.co.jp

※1: IEEE754の32ビットの単精度浮動小数点数では、仮数部23ビットに暗黙的な1ビットを加えた24ビットの精度を持ち、これを10進数に直すとおよそ7.22になることから