はじめに
「注文金額の合計を出したら、なぜか0.01円ずれている」——こんなSQLを書いたことはありませんか。
SELECT SUM(order_total) FROM Orders;
-- 期待: 100000.00
-- 実際: 99999.99999999999
型を「小数が入るかどうか」「何文字入るか」だけで選んでいると、こうしたズレの原因が型選択まで遡らないまま、別の場所を直そうとして時間を溶かしがちです。
同じ構造の落とし穴は、固定値リストや画像の保存方法など、別の型選択にも顔を出します。
TL;DR
- データ型の選択は「サイズや精度」ではなく「この列が表す意味」の宣言です。意味と型がズレると、書いた瞬間は動いても、後から静かに壊れます
- 代表的なミスマッチは次の3つです
- 金額に
FLOATを使う
→ 正確な値なのに、近似値として保存してしまう - 業務で増減する値を
CHECK制約やENUMに埋め込む
→ データとして変わるものを、スキーマに固定してしまう - 画像をファイルパス文字列だけで管理する
→ 実体データをDBの保証範囲の外に出してしまう
- 金額に
- 型を選ぶ場面では「物理的に入るか」ではなく「DBに何を保証してもらいたいか/何を諦めるか」を毎回問います
1. 型は「サイズ」ではなく「意味」を宣言する
このあと見る3つのミスマッチは、すべて「型に込めた意味と、列の実体がズレている」という同じ構造を持ちます。先にこの上位軸を1つだけ押さえます。
なお、以下のSQL例は説明のために簡略化しています。型名やDDL構文はDB製品によって少し異なりますが、ここでは「型が何を保証するか」という考え方に絞って説明します。
1.1 型はDBとの「約束」
型を宣言することは、単に格納サイズや桁数を決める作業ではありません。
「この列にはどんな意味の値が入り、DBに何を保証してもらうのか」を決める作業です。
型を間違えると、書いた瞬間は動いても、後から意味のズレが問題として表面化します。
1.2 サイズ視点と意味視点の違い
同じ「数値が入る列」でも、それが金額なのか、センサーから取得する測定値なのかで、適切な型は変わります。
「数値が入ればいい」というサイズ視点だけで選ぶと、後で意味のズレが顕在化します。
| 観点 | サイズ視点 | 意味視点 |
|---|---|---|
| 何を見て選ぶか | 何バイトか/何桁入るか | 業務上この列が表す意味は何か |
| 決まること | 物理的に格納できるかどうか | DBが何を保証してくれるか |
| 変更コスト | 後から型を変えるとデータ移行が必要 | 意味が合っていれば変更は不要 |
サイズ視点だけの設計が危険なのは、「物理的に入る」と「意味として正しい」が必ずしも一致しないからです。
1.3 これから見る3つの典型
以降では、型と意味がズレる代表的な3つの場面を扱います。
金額に近似値の型を使う場面、固定値リストをテーブル定義の中に埋め込む場面、画像をDBの外に置いてパス文字列だけ持たせる場面です。
いずれも「列の意味」と「型が保証する範囲」がズレている、という同じ構造を持っています。
2. 金額にFLOATを選ぶと、意味が静かに壊れる
「小数が入るならとりあえずFLOAT」は、最も踏みやすい型ミスマッチです。
最初の具体例として、金額にFLOATを使うとなぜ意味として不適切なのかを見ていきます。
2.1 失敗事例 — 注文金額をFLOATで持ったら合計がズレた
ECサイトの注文テーブルで、注文金額の列を FLOAT で定義したとします。
CREATE TABLE Orders (
order_id SERIAL PRIMARY KEY,
order_total FLOAT
);
INSERT INTO Orders (order_total) VALUES (59.95);
入れた値を SELECT で取り出すと、見た目は 59.95 で返ってきます。
ところが、桁を増やすために10億倍して取り出すと、思わぬ値が顔を出します。
以下は単精度4バイトのFLOAT(たとえばMySQLの FLOAT)の場合の例です。
SELECT order_total * 1000000000 FROM Orders WHERE order_id = 1;
-- 期待: 59950000000
-- 実際: 59950000762.939
実際に表示される値はDB製品やクライアントの表示形式によって変わりますが、FLOAT が10進小数を正確に保存する型ではなく近似値で持つ、という性質は共通です。
このズレは等価比較でも顔を出します。
WHERE order_total = 59.95 のような等価比較でも、期待どおりに扱えないことがあります。
テスト時には気づきにくく、桁を増やしたり、等価比較したり、長期間にわたって累積計算した瞬間に壊れる——という静かな壊れ方をします。
なお、倍精度8バイトの DOUBLE を使った場合は誤差ははるかに小さくなりますが、「近似値である」という性質自体は同じです。
2.2 なぜ意味がズレるか — FLOATは「近似値」の型
FLOATは、IEEE 754という標準規格に従って実数を2進数の有限桁で格納する型です。
10進数で有限桁の値(59.95 など)も、2進数で表すと無限桁になる場合があり、有限ビットに丸めて保存します。
10進: 59.95(有限桁)
↓ 2進数に変換
2進: 111011.111100110011001100... (循環無限桁)
↓ 有限ビット(例: 32bit)に丸める
格納値: 59.95 にきわめて近い別の値
つまりFLOATを選ぶことは、「この列の値は近似値で構わない」とDBに宣言していることになります。
一方、金額という列の意味は「正確に決まる値」です。
59.95ドルは59.95ドルであって、59.950000762939ドルではありません。
「近似値で構わない」という型の意味と、「正確に決まる値」という列の意味は、根本からズレています。
等価比較で期待どおりに扱えなかったり、累積で誤差が育ったりするのは、近似値という意味の自然な帰結です。
2.3 直し方 — 「正確な10進数」の意味を持つ型を選ぶ
金額のように「10進で正確に決まる値」を扱う列には、NUMERIC(p, s) または DECIMAL(p, s) を使います。
精度(precision、総桁数)とスケール(scale、小数点以下の桁数)を指定し、10進数のまま正確に格納する型です。
CREATE TABLE Orders (
order_id SERIAL PRIMARY KEY,
order_total NUMERIC(10, 2) -- 総10桁、小数点以下2桁まで正確
);
INSERT INTO Orders (order_total) VALUES (59.95);
SELECT order_total * 1000000000 FROM Orders WHERE order_id = 1;
-- 結果: 59950000000 ← 期待通り
NUMERIC を選ぶことは「この列は近似ではなく正確な10進数を保証する」という意味の宣言です。
等価比較も累積計算も、直感どおりの結果が返ってきます。
2.4 FLOATが意味として正しい領域もある
ここで誤解を避けたいのは、「FLOATは常に悪」ではないということです。
FLOATが意味として適切なのは、そもそも近似値として扱う対象です。
たとえばセンサーから取得する気温・湿度、物理シミュレーションの結果、機械学習モデルが出す確率値などです。
これらの列で行う演算は、等価比較ではなく範囲比較(BETWEEN)や集計(AVG、MAX)が中心で、誤差は業務上の意味に影響しません。
「FLOATは近似値の型である」という意味を理解していれば、選ぶべき場面は自然に分かります。
3. 固定値をCHECK/ENUMに埋め込むと、変更がスキーマ変更になる
「取りうる値が決まっているなら CHECK制約 か ENUM で縛るのが安全」と教わると、つい全部それで済ませたくなります。
けれどもこれは、値とメタデータの境界を間違えた選択になりがちです。
3.1 失敗事例 — ロール追加にDB停止が必要になった
SaaSのユーザー権限管理で、role 列に取りうる値を埋め込んだとします。
CHECK制約 は列の値が条件を満たすかをDBに検証させる仕組み、ENUM は取りうる値のリストを列定義に埋め込むMySQL独自の型です。
CREATE TABLE Users (
user_id SERIAL PRIMARY KEY,
role VARCHAR(20)
CHECK (role IN ('admin', 'editor', 'viewer'))
);
-- MySQLなら ENUM でも書ける
-- role ENUM('admin', 'editor', 'viewer')
ここまでは何の問題もありません。問題が起きるのは、顧客から「請求担当のロール(billing)を追加してほしい」と言われた瞬間です。
-- 値を1つ追加するだけで、CHECK制約の張り直しが必要
ALTER TABLE Users DROP CONSTRAINT users_role_check;
ALTER TABLE Users ADD CONSTRAINT users_role_check
CHECK (role IN ('admin', 'editor', 'viewer', 'billing'));
製品によっては、CHECK制約の付け直しでテーブル全体の再検証が走り、その間テーブルアクセスを抑える必要があります。
「メニュー項目を1つ増やすだけで、DBの停止計画やマイグレーション計画が立つ」という状態に陥ります。
コスト自体は製品とやり方によって変わります(MySQLの ENUM は末尾への値追加ならテーブル再構築なしで完了する版もあります)。
ただし「値リストがメタデータに埋め込まれ、変更にスキーマ操作が必要になる」という構造自体は共通です。
3.2 なぜ意味がズレるか — 値リストは「データ」である
ここで起きているのは、メタデータとデータの境界の取り違えです。
メタデータとは、テーブル定義そのもの(列名・型・制約など)の情報で、テーブルに入る個々の行(データ)と区別される情報です。
CHECK制約 や ENUM で値リストを書くと、その値リストは「列定義の一部」、つまりメタデータになります。
メタデータは本来「滅多に変わらないもの」を置く場所で、変更にはテスト・QA・マイグレーション計画が伴う前提のものです。
ロール一覧のような「業務の都合で増減するリスト」は、本来はデータとして、行で持つべきものです。
業務で変わりうるものを「変わらない前提のメタデータ」に置いた瞬間、変更コストが跳ね上がります。
値を1つ追加するときに、2つのルートを比べてみます。
副次的な問題もあります。
値リストをクエリで取得しにくく(システムビューを舐めて文字列を解析する羽目になる)、結果としてアプリ側に値リストを二重管理することになりがちです。
3.3 直し方 — 参照テーブル + 外部キー制約で「データとして」持つ
値リストを別テーブル(参照テーブル)に行として持ち、元の列はその参照テーブルを外部キーで参照させます。
参照テーブルは「許可する値を行として持つテーブル」、外部キー制約は「他テーブルの値しか入らないことをDB側で強制する制約」です。
CREATE TABLE Roles (
role VARCHAR(20) PRIMARY KEY
);
INSERT INTO Roles (role) VALUES ('admin'), ('editor'), ('viewer');
CREATE TABLE Users (
user_id SERIAL PRIMARY KEY,
role VARCHAR(20),
FOREIGN KEY (role) REFERENCES Roles(role)
ON UPDATE CASCADE
);
-- 値の追加は INSERT 1行で済む
INSERT INTO Roles (role) VALUES ('billing');
ON UPDATE CASCADE は、参照先の値が変わったときに参照元も自動で追従させる指定です。
これで、ロール名の変更も Roles テーブル側の UPDATE 1つで全ユーザーに伝播します。
廃止する値の扱い(過去データを残しつつ新規入力だけ止める)は、active のような属性列を参照テーブルに追加して切り替える方法があります。
詳細は深入りしませんが、CHECK制約 や ENUM ではできない柔軟さがここにあります。
3.4 CHECK/ENUMが意味として正しい領域もある
逆に、「値が本当に不変」と断言できる場合は CHECK制約 や ENUM を選んでよい場面です。
判断軸は「この値リストは、業務の変化に対して動かないと約束できるか」です。
業務の都合で追加・無効化・表示名変更などが起きそうなら、参照テーブルでデータとして扱う方が変更に強くなります。
たとえば「有効/無効」「オン/オフ」のような相互排他的な2値は、業務がどう変わってもこの2つから増減することはほぼなく、メタデータに埋め込んでも安全です。
なお、CHECK制約 には「値の制限」以外の使い道もあります。
たとえば「開始時刻 < 終了時刻」のように、列同士の関係を検証する用途です。
値リストを固定するのは CHECK制約 の唯一の使い方ではありません。
4. 画像をファイルパスで持つと、DBの保証範囲の外に出る
画像のようなバイナリは「DBには重いので外に置いて、パスだけ保存」が反射的に選ばれがちです。
けれどもこの選択は、DBが保証する整合性・トランザクション・バックアップの範囲外にデータを出すことを意味します。
型が VARCHAR になった瞬間、それは「ただの文字列」であり、実体ファイルが本当に存在するかをDBは知りません。
4.1 失敗事例 — リストアしたら画像が全部表示されない
ユーザープロフィール画像をファイルパスで保存していたシステムがあるとします。
/var/app/uploads/ 配下に画像本体を置き、DBには image_path VARCHAR(255) だけを持たせる設計です。
CREATE TABLE Users (
user_id SERIAL PRIMARY KEY,
user_name VARCHAR(50),
image_path VARCHAR(255) -- '/var/app/uploads/user_42.jpg' などが入る
);
ある日、障害が起きてDBを最新バックアップから復元しました。
けれども、/var 配下はバックアップ対象外で、画像本体は失われていました。
DB上のレコードは復元されたものの、参照先のファイルが存在しないので、画像はすべて表示されません。
「DBが復元できた = データが復元できた」と思っていたのに、復元できたのはパス文字列だけだった、というシナリオです。
4.2 なぜ意味がズレるか — DBが保証するのはDBの中身だけ
DBの強みは、トランザクション(一連のDB操作をまとめて取り消し可能にする単位)、整合性、バックアップを保証してくれることです。
コミットまで他のクライアントに変更が見えず、ロールバックすれば変更がなかったことになります。
バックアップから復元すれば、その時点のデータが戻ります。いずれも強力な保証です。
ただし、これらの保証はすべてDBの中に入っているデータに対してのみ働きます。
型が VARCHAR であるということは、DBから見れば「ただの文字列」で、実体ファイルの存在・整合性・権限はすべてアプリ側で管理する責任になります。
ロールバックの場面では、この境界が特にはっきりします。
ロールバックでDB側の削除は取り消されますが、ファイルシステム側の unlink は取り消されません。
DB側だけがロールバックされ、ファイルは消えたままになります。
同じ構造の壊れ方が、削除整合性/バックアップ/アクセス権限のすべてで顔を出します。
| 観点 | ファイルパス + VARCHAR | BLOB(DB内に格納) | オブジェクトストレージ参照 |
|---|---|---|---|
| 行と画像の削除整合性 | アプリで自分で同期する | DBが自動で連動 | アプリで自分で同期する |
| ロールバック | ファイルは戻らない | 画像も戻る | オブジェクトは戻らない |
| バックアップ | 別手順が必要・同期保証なし | DBバックアップに含まれる | ストレージ側で別途設計 |
| アクセス権限 | OS側で別途管理 | SQLのGRANT/REVOKEが効く | ストレージ側のIAMで管理 |
要点は、ファイルパス + VARCHARの方式は「DBの保証範囲」と「外側」の境界が、設計者の意図しないところに引かれている、という点です。
4.3 直し方 — 「DBに任せる」か「外で取る覚悟を持つ」かを意識的に選ぶ
直し方は1つの軸で整理できます。整合性を誰が保証するかを意識的に選ぶことです。
ひとつめは、DBに任せる選択肢です。
BLOB(バイナリデータをそのまま格納する型)で画像本体をDBに格納します。
行と同じトランザクション・バックアップ・権限管理の下に置かれ、DELETE すれば画像も消え、ROLLBACK すれば画像も戻ります。
CREATE TABLE Users (
user_id SERIAL PRIMARY KEY,
user_name VARCHAR(50),
profile_image BLOB -- 画像バイナリをDB内に直接格納
);
ただし、BLOBにするとDBサイズやバックアップ時間、配信性能には影響するため、常にBLOBが正解というわけではありません。
もうひとつは、外で取る覚悟をする選択肢です。
現代のWebアプリでは、画像本体はオブジェクトストレージに置き、DBにはそのURLやキーを保存する設計が一般的です。
オブジェクトストレージは、ファイルをHTTP経由で出し入れするクラウドサービス(Amazon S3など)を指します。
CREATE TABLE Users (
user_id SERIAL PRIMARY KEY,
user_name VARCHAR(50),
image_url VARCHAR(500)
-- 行を消したらS3のオブジェクトも消す処理をアプリ側で明示
-- ロールバック時のアップロード済みオブジェクトの扱いもアプリ側で設計
-- 耐久性・バージョニング・アクセス制御はS3側の設定に明示的に任せる
);
見た目は「ファイルパス方式」と似ていますが、決定的に違うのは、外側で何を保証してもらうかを設計者が意識して引き受けていることです。
一番危ないのは、反射的なファイルパス + VARCHARです。
DBの整合性は外れているのに、外側で何を保証してもらうかも決まっていない——結果として、何も保証されていない状態になります。
5. 3つから抽出する判断軸 — 型が保証するものと壊しうるもの
3つの典型を別々に見てきましたが、すべて「型を選んだ時点で何が保証され、何が保証されないかが決まる」という同じ構造を持っています。
最後にその共通軸を抽出して、別の型選択の場面で使える形にします。
5.1 3つに共通する構造
どの型ミスマッチも、同じ流れを辿ります。書いた瞬間は動くものの、意味は合っていません。
そして普通の運用イベント(桁を増やす/値を1つ足す/障害復旧する)で壊れ、直すコストが高くつきます。
| 型ミスマッチ | DBが保証するもの | 壊れうるもの | 意味として正しい使い方 | 代替案 |
|---|---|---|---|---|
| 金額にFLOAT | 近似値の格納と範囲計算 | 等価比較、累積計算の正確さ | 測定値・物理量など近似でよい列 |
NUMERIC(p, s) / DECIMAL(p, s)
|
| 固定値にCHECK/ENUM | 値が固定範囲内にあること | 値追加・更新の容易さ、移植性 | 業務上不変な2値(有効/無効など) | 参照テーブル + 外部キー制約 |
| 画像をファイルパス + VARCHAR | パス文字列の存在 | ファイル自体の整合性、ロールバック、バックアップ | 単一クライアントで整合性要件が低い場面 |
BLOB または S3 + 明示的な整合性設計 |
5.2 型選択時に毎回問う2つの質問
このマトリクスの背後にあるのは、シンプルな2つの問いです。
- Q1: この型を選ぶと、DBは何を保証してくれるのか?
- Q2: この型を選ぶと、何が保証されなくなるのか?
Q1とQ2のトレードオフが、業務上の「列の意味」と合致しているかを毎回問います。意味が合っていれば、選んだ型は長く動きます。
おわりに
型は「サイズ」ではなく、DBとの約束です。列に込めた意味と、型が約束する範囲を一致させるのが、設計の出発点になります。
書いた瞬間に動くかではなく、桁を増やしたとき、値を1つ足したいとき、障害復旧したときにも約束が守られるかを問います。
今回扱った3つは氷山の一角で、同じ構造のミスマッチは他の型選択の場面にも顔を出します。
「DBに何を保証してもらうか」を毎回問う癖が、長く動くスキーマへの近道です。