多くの開発者が何気なく使っているAutoIncrementについてみなさんどう思いますか?
「え,AutoIncrementって便利だし,みんな使ってるじゃん?」って思った方,まさにその通り!でも,実は現代のWebサービスには結構危険な存在になってしまったのではないでしょうか?
目次
そもそもAutoIncrementって何?
AutoIncrementは,データベースの「番号札システム」みたいなものです.銀行の順番待ちでらう番号札を想像してみてください.
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY, -- これが番号札!
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
新しい商品を追加するたびに,1,2,3,4...と自動で番号が振られていきます.「簡単でいいじゃん!」と思いますよね.
AutoIncrementの「え,マジで?」な問題点
1. スケールすると大パニック!
複数店舗で同じ番号札を配る悲劇
想像してみてください.全国に展開している銀行で,各支店が独立して番号札を配っているとします...
東京支店: 1番,2番,3番,4番...
大阪支店: 1番,2番,3番,4番... ← あれ?同じ番号!?
これがまさにシャーディング(データベース分散)で起こる問題です.各データベースサーバーが勝手に同じIDを生成してしまうんです.
マスター・スレーブ構成で起こる「誰が本物?」問題
複数のデータベースサーバーが同期を取ろうとしたとき,同じIDで違うデータが作られちゃう可能性があります.もう混乱の極みです.
2. セキュリティが丸見え状態
これが一番「えー!」ってなる問題かもしれません.
あなたのサービス,データ丸見えです
連続した数字のIDは,あなたのサービスについて色々なことを教えてしまいます:
- 「うちの会員数バレてる!」: ID 10000と20000の差を見れば,約10000人の会員がいることがわかっちゃいます
- 「成長率もバレてる!」: 1週間でIDが1000から1500に増えたら,「週500人ペースで成長してるんだな」とわかります
- 「他人のデータが見放題!」: URLのIDを変えるだけで,他のユーザーのページにアクセスできちゃう可能性が...
# 悪意のあるユーザーの思考
https://yoursite.com/users/1001 ← 自分のプロフィール
https://yoursite.com/users/1002 ← 隣の番号も見てみよう!
https://yoursite.com/users/1003 ← こっちも!
怖くないですか?
実際にあった怖い話
某サービス(私の自作のサービス...未公開ですが....)で,ユーザーIDが連番だったせいで:
- 競合他社に正確なユーザー数を把握された
- URLのIDを変更するだけで他ユーザーの個人情報が見えた()
- サービスの成長率や活動度が筒抜けになった
なんてことが実際に起こりました.これは認証をしっかりすれば回避はできると思いますが...データベース側でも意識したいセキュリティ事項ではありますね!
3. パフォーマンスも実は微妙
人気ラーメン店の行列問題
AutoIncrementは常に「一番大きい番号 + 1」を作ります.これって,人気ラーメン店の行列と同じで,みんなが同じ場所(データベースの同じ部分)に殺到するんです.
結果として:
- ロック競合が発生しやすい
- 大量データの一括登録で性能が落ちる
- B-treeインデックスの右端だけが「熱い」状態になる
4. 分散システムでは完全にお手上げ
マイクロサービス時代の現在,各サービスが独立してIDを生成する必要があります.でもAutoIncrementだと...
ユーザーサービス: user_id = 1, 2, 3...
商品サービス: product_id = 1, 2, 3... ← 被ってるけど大丈夫?
注文サービス: order_id = 1, 2, 3... ← これも被ってる!
各サービス間でのIDの一意性を保証するのが超困難になります.
5. 一番の問題:開発者がIDをコントロールできない!
これが実は最も深刻な問題かもしれません.
IDの生成タイミングが制御不能
AutoIncrementでは,IDはINSERT文が実行された瞬間にデータベースが勝手に決めます.つまり:
// こんなことができない!
product := Product{
ID: "好きなIDを指定したい", // ← これできない
Name: "商品名",
}
データ移行・テスト時の悪夢
- 本番データのID: 1, 2, 3, 4, 5...
- テストデータのID: 1, 2, 3...
「本番のID=123の商品をテスト環境で再現したい」と思っても,AutoIncrementだと制御できません.
バックアップ・復元時の混乱
-- 本番DBのバックアップを開発環境に復元
-- でも新しいデータを追加すると...
INSERT INTO products (name) VALUES ('新商品');
-- ↑ IDが予想と違う値になることがある
APIレスポンスの一貫性問題
{
"user_id": 12345,
"profile_id": 67890, // 別テーブルのAutoIncrement
"post_id": 111 // また別のAutoIncrement
}
各IDの関係性や意味が全くわからず,デバッグ時に「このIDって何だっけ?」となりがちです.
そもそもAutoIncrementって本当に必要?
ここで根本的な疑問を投げかけてみましょう.
「AutoIncrementしたIDを使うケースって,実はあまりないのでは?」
ケース1: ユーザーID
-- AutoIncrement版
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY, -- これ必要?
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL
);
でも実際のところ,アプリケーションでは:
- ログイン時は
email
やusername
で検索 - プロフィール表示も
username
ベース - API呼び出しも
/users/john_doe
みたいな感じのケース
XのURLも https://X.com/JavaLangRuntime みたいな感じですね.
「連番IDって,実際に使ってる?」
ケース2: 商品ID
-- AutoIncrement版
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY, -- これも必要?
sku VARCHAR(50) UNIQUE NOT NULL, -- 実際はこれを使う
name VARCHAR(255) NOT NULL
);
実際の業務では:
- 商品管理は
sku
(商品コード)ベース - 在庫管理も
sku
ベース - URLも
/products/ABC-123
(SKUベース)
「またしても,連番IDの出番がない...」
ケース3: 注文ID
-- AutoIncrement版
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY, -- これも?
order_number VARCHAR(50) UNIQUE NOT NULL, -- 実際はこれ
user_id INT NOT NULL
);
業務的には:
- 顧客には
order_number
を伝える(例:ORD-2024-001234) - 追跡や問い合わせも
order_number
ベース - 領収書にも
order_number
が印字される
「またまた,連番IDの存在意義が...」
AutoIncrementが本当に活躍するケース
実は,AutoIncrementが本当に必要なケースって,意外と少ないんです:
1. ログテーブル
-- これは連番でもOK(むしろ楽)
CREATE TABLE access_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(45)
);
ログは「とにかく記録する」のが目的で,個別のIDに意味はありません.
2. 中間テーブル(関連テーブル)
-- ユーザーと権限の関連
CREATE TABLE user_roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
role_id VARCHAR(36) NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
関連性を表すだけなので,IDに特別な意味は不要です.
3. 一時的なワークテーブル
バッチ処理やデータ加工用の一時テーブルなど.
「別カラムでIDを管理」は本末転倒?
よくある「回避策」がこれです:
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY, -- 一応残す
product_id VARCHAR(36) UNIQUE NOT NULL, -- 本当のID
name VARCHAR(255) NOT NULL
);
でも,これって:
- ストレージ無駄遣い: 2つのインデックスが必要
- 開発者の混乱: どっちがメインID?
- パフォーマンス劣化: JOINが複雑になる
- メンテナンス負荷: 2つのIDを管理する必要
現実的な解決策
-- スッキリ!
CREATE TABLE products (
product_id VARCHAR(36) PRIMARY KEY, -- これだけでOK
name VARCHAR(255) NOT NULL,
sku VARCHAR(50) UNIQUE NOT NULL
);
メリット:
- シンプル: IDが1つだけ
-
明確:
product_id
という名前で用途がわかる - 制御可能: 開発者がIDを決められる
- セキュア: 推測不可能
- 拡張性: 分散環境でも安心
代替案
「じゃあどうすればいいの?」という声が聞こえてきそうですね.安心してください,素晴らしい代替案があります!
1. UUID - 「絶対にかぶらない番号」
UUIDは128bitの超長い番号で,「実質的に絶対かぶらない」と言われています.
いいところ
- 地球上で絶対にかぶらない: 理論上,全人類が一生懸命UUIDを作り続けても,かぶる確率は宝くじより低い
- 分散環境でも安心: どこで作っても大丈夫
- 推測不可能: 隣のIDを当てるのは不可能
- 事前に作れる: データベースに保存する前にIDを決められる
ちょっと困るところ
- 長い: 16バイトもある(AutoIncrementは4バイト)
-
見た目が不親切:
550e8400-e29b-41d4-a716-446655440000
← 人間には読みにくい - インデックス性能: ランダムすぎて,時々インデックスが頑張りすぎる
CREATE TABLE products (
id CHAR(36) PRIMARY KEY, -- UUIDはこんな感じ
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
2. ULID - 「UUIDの進化版,時系列ソート対応!」
ULIDは「UUIDは良いけど,時系列順にソートできたら最高なのに...」という願いから生まれました.
すごいところ
- 時系列でソート可能: 作成順に並べられる
- UUIDより短い表現: 26文字で済む
- 推測困難: セキュアです
- 高い一意性: UUIDと同等
01ARZ3NDEKTSV4RRFFQ69G5FAV ← こんな感じ,ちょっとスッキリ
3. Snowflake ID - 「Twitterが生み出した神システム」
Twitterが「1秒間に数万ツイートが投稿される環境で,どうやってユニークなIDを作るか」を考えて生み出した64bitのID生成システムです.
構成要素(これが天才的!)
- タイムスタンプ (41bit): いつ作られたか
- マシンID (10bit): どのサーバーで作られたか
- シーケンス番号 (12bit): 同じミリ秒内での連番
素晴らしいところ
- 超高速生成: 1秒間に数百万個作れる
- 時系列ソート: 作成順に並ぶ
- 64bitで効率的: AutoIncrementの倍のサイズだけど,それでも軽量
- 分散環境完全対応: 各サーバーが独立してIDを作れる
4. カスタムIDジェネレーター - 「自分だけの特別なID」
業務要件に合わせて,独自のID生成ルールを作る方法です.
// 例:商品なら「PROD_」,ユーザーなら「USER_」で始まるID
func generateCustomID(prefix string) string {
timestamp := time.Now().Unix()
random := rand.Intn(999999)
return fmt.Sprintf("%s_%d_%06d", prefix, timestamp, random)
}
// 結果: "PROD_1645123456_123456" ← 見ただけで商品IDってわかる!
実際にコードで書いてみよう
実際にGoのプロジェクトではどのようにIDを管理しているかを見てみましょう.
Go言語でUUIDを使ってみる
package main
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Product struct {
ID string `gorm:"type:char(36);primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
CreatedAt time.Time `json:"created_at"`
}
// これでUUIDが自動生成される!
func (p *Product) BeforeCreate(tx *gorm.DB) error {
if p.ID == "" {
p.ID = uuid.New().String() // 魔法の一行
}
return nil
}
ULIDも試してみよう
package main
import (
"github.com/oklog/ulid/v2"
"crypto/rand"
"time"
)
// これでULIDが自動生成される!
func generateULID() string {
entropy := rand.Reader // ランダム要素
ms := ulid.Timestamp(time.Now()) // タイムスタンプ要素
id, _ := ulid.New(ms, entropy)
return id.String() // 時系列ソート可能なID完成!
}
Snowflake IDで爆速ID生成
package main
import (
"github.com/bwmarrin/snowflake"
)
// Snowflake IDを初期化する関数
func initSnowflake(machineID int64) *snowflake.Node {
node, err := snowflake.NewNode(machineID)
if err != nil {
panic(err)
}
return node
}
// これでSnowflake IDが自動生成される!
func generateSnowflakeID(node *snowflake.Node) int64 {
return node.Generate().Int64() // 超高速で一意ID生成!
}
データベース設計の違いを見てみよう
-- 従来のAutoIncrement方式(危険!)
CREATE TABLE products_old (
id INT AUTO_INCREMENT PRIMARY KEY, -- 推測可能で危険
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
-- UUID方式(安全だけどちょっと大きい)
CREATE TABLE products_uuid (
id CHAR(36) PRIMARY KEY, -- 36文字のランダム文字列
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
-- ULID方式(安全で時系列ソート可能)
CREATE TABLE products_ulid (
id CHAR(26) PRIMARY KEY, -- 26文字でスッキリ
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
-- Snowflake方式(高速で効率的)
CREATE TABLE products_snowflake (
id BIGINT PRIMARY KEY, -- 64bitの数値
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
どれを選べばいいの?性能バトル
方式 | サイズ | 生成速度 | ソート性能 | 分散対応 | セキュリティ | 使いどころ |
---|---|---|---|---|---|---|
AutoIncrement | 4 bytes | 高速 | 最高 | ❌ | ❌ | 小規模・単一DB |
UUID v4 | 16 bytes | 普通 | 微妙 | ✅ | ✅ | セキュリティ重視 |
ULID | 16 bytes | 普通 | 良い | ✅ | ✅ | バランス重視 |
Snowflake | 8 bytes | 超高速 | 良い | ✅ | ✅ | 高負荷対応 |
既存システムからの脱出作戦
「うちのシステム,すでにAutoIncrement使ってるんだけど...」という方へ.大丈夫,段階的に移行できます!
作戦1: 段階的移行
type Product struct {
// 古いID(徐々に使わなくする)
LegacyID int `gorm:"column:legacy_id;autoIncrement" json:"legacy_id,omitempty"`
// 新しいID(メインで使う)
ID string `gorm:"type:char(36);primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
}
作戦2: デュアルキー運用
新旧両方のIDを並行運用して,徐々に新IDに切り替える方法です.
-- 既存テーブルに新しいIDカラムを追加
ALTER TABLE products
ADD COLUMN uuid_id CHAR(36) UNIQUE;
-- 既存データにUUIDを付与
UPDATE products SET uuid_id = UUID() WHERE uuid_id IS NULL;
-- 新しいレコードは最初からUUIDを使用
-- 古いレコードへのアクセスは段階的にUUID経由に変更
結局どれを選べばいいの?
最後に,「結局うちのプロジェクトには何が良いの?」という疑問にお答えしましょう.
📱 小規模なWebアプリ・プロトタイプ
→ ULID がおすすめ
- 将来の拡張に備えつつ,シンプルに始められる
- 時系列ソートできるから便利
🚀 スタートアップ・成長期のサービス
→ UUID がおすすめ
- セキュリティ重視
- いつ分散環境に移行してもOK
- 投資家に「セキュリティ意識が高い」とアピールできる(笑)
⚡ 高負荷・大規模サービス
→ Snowflake ID がおすすめ
- パフォーマンス重視
- Twitter規模でも実績あり
- エンジニアから「おお,本格的だな」と思われる
🏢 エンタープライズ・業務システム
→ カスタムID がおすすめ
- 業務ルールに合わせられる
- 見ただけで何のデータかわかる
- 運用担当者に優しい
AutoIncrementは確かに便利なのですが近年のWebサービスではさまざまな点で危険なポイントがあります.
危険な理由
- 🔓 セキュリティリスク: 情報がダダ漏れ
- 📈 スケーラビリティの限界: 分散環境で破綻
- 🐌 パフォーマンス問題: 高負荷時にボトルネック
現代的な解決策
- UUID: セキュリティ最優先
- ULID: バランス重視
- Snowflake ID: パフォーマンス重視
- カスタムID: 業務要件重視
特に新規開発の場合は,「最初からAutoIncrementを使わない」ことを強くおすすめします.後から変更するより,最初から適切な方式を選んでおく方が絶対に楽ですからね.
みなさんも,次のプロジェクトでは「脱AutoIncrement」を検討してみてください.きっと,より安全で拡張性の高いシステムを構築できるはずです!