Loading post
Jun 28, 2026

機能ロールアウト、A/Bテスト、ログサンプリングでよく使われる「決定的バケッティング」を、ハッシュを知らない人向けに最初から説明します。
プロダクトを作っていると、「全体のうち一部だけ」を選びたい場面がよくあります。
新機能を、まずは一部のユーザーだけに出したい
A/Bテストで、ユーザーの10%だけを実験群に入れたい
重いログ処理を、全リクエストではなく1%だけにかけたい
新しいAIモデルを、まずは一部の会話だけで裏側実行したい
どれも根本は同じです。
たくさんある対象から、約5%や約10%だけを、毎回同じように選びたい。
この記事では、具体例としてカスタマーサポートの会話を使います。ただし、同じ考え方はユーザー、アカウント、ワークスペース、リクエストなどにも使えます。
たとえばカスタマーサポートのシステムに、新しいAIモデルを追加するとします。そのモデルは会話を読んで、問い合わせ内容を分類したり、優先度を推定したり、担当者への補助情報を作ったりします。
最初から全会話に対して本番動作させるのではなく、まずは小さく試したくなります。
まずは シャドウモード で動かす
シャドウモードでは、モデルは裏側で会話を読み、タグ付けや判定だけを行う
ただし、その結果で実際の画面やワークフローは変えない
最初は全体の 約5% だけに流す
問題がなければ 25%、50%、100% と段階的に増やす
このように、対象を少しずつ増やしていくやり方を 段階的ロールアウト と呼びます。Feature flag や A/B テストの仕組みでもよく出てくる考え方です。
一見すると、「会話の5%を選ぶだけ」なので簡単そうです。ランダムに5%を選べばよさそうに見えます。
でも、実際には少し注意が必要です。
今回やりたいことは、単に「5%の確率で選ぶ」ことではありません。欲しい性質を分解すると、少なくとも次の4つがあります。
100件あればだいたい5件、100,000件あればだいたい5,000件が対象になる、という状態にしたい。
1つの会話は何度も処理されます。最初に作られたとき、顧客が返信したとき、担当者が更新したときなどです。
あるタイミングでは「対象」、次のタイミングでは「対象外」になると、その会話のデータが途中だけ欠けます。シャドウモードの評価もしづらくなります。
5%から25%に増やすとき、すでに5%に入っていた会話はそのまま対象であり続けてほしい。
もし割合を変えるたびに全体を選び直すと、これまで追っていた会話が突然外れることがあります。それは段階的ロールアウトとして扱いづらい。
「この会話は対象にした」というフラグをデータベースに保存すれば、同じ会話を毎回同じ判定にできます。
ただし、そのためだけに全会話へフラグを書き込むのは重いです。できれば、会話IDからその場で判定したい。
この4つを同時に満たすために使えるのが、ハッシュを使った 決定的サンプリング です。
Math.random() では何が困るのかまず、いちばん素朴なコードを考えます。
if (Math.random() < 0.05) {
// この会話をサンプリングする
}
このコードは「その瞬間に5%の確率で true になる」という意味では正しいです。
でも、同じ会話を複数回チェックすると困ります。Math.random() は呼ぶたびに違う値を返すからです。
最初の処理 : 対象
返信 #1 : 対象外
返信 #2 : 対象
このように、同じ会話が対象になったり外れたりします。会話全体を観察したいのに、一部だけ拾って残りを取りこぼす状態になります。
では、最初に1回だけ Math.random() を呼び、その結果を保存すればよいでしょうか。
それでも動きます。ただし、その場合は会話ごとに「対象かどうか」を保存する必要があります。今回欲しいのは、保存なしで毎回同じ答えを出す方法です。
保存せずに同じ答えを出したいなら、毎回同じ入力から毎回同じ値を作ればよいです。
ここでは、会話IDから固定の番号を作ります。
conversation_12345 -> 42
conversation_12346 -> 7
conversation_12347 -> 81
この番号を バケット と呼びます。バケットは「仕分け先の箱」くらいの意味です。
まずは説明を簡単にするために、バケットが 0 から 99 までの100個あると考えます。
0 1 2 3 4 5 6 ... 97 98 99
5%だけを対象にしたいなら、番号が 5 未満の会話を対象にします。
対象
┌───────────────┐
0 1 2 3 4 5 6 ... 97 98 99
└──── < 5 ─────┘
25%に増やしたいなら、しきい値を 25 にします。
対象
┌───────────────────────────────────────────────┐
0 1 2 3 4 5 6 ... 23 24 25 ... 99
└──────────────────── < 25 ────────────────────┘
会話ごとのバケット番号は変わりません。変わるのは、どこまでを対象にするかというしきい値だけです。
| 会話のバケット | 5%のとき (< 5) | 25%のとき (< 25) | 50%のとき (< 50) |
| --- | --- | --- | --- |
| 3 | 対象 | 対象 | 対象 |
| 24 | 対象外 | 対象 | 対象 |
| 37 | 対象外 | 対象外 | 対象 |
| 82 | 対象外 | 対象外 | 対象外 |
これで次の性質が得られます。
同じ会話IDは、毎回同じバケットになる
5%から25%に上げても、5%に入っていた会話は外れない
バケットはIDから計算するので、データベースに保存しなくてよい
残る問題は1つです。
どうやって、会話IDを偏りの少ないバケット番号に変換するのか。
そこでハッシュを使います。
ハッシュは、入力を固定長の数値に変換する関数です。
"conversation_12345" -> 3287581120
"conversation_12346" -> 1529084417
"conversation_12347" -> 409817263
重要なのは、次の2点です。
同じ入力なら、必ず同じ出力になる
似た入力でも、出力はばらけて見える
たとえば conversation_12345 と conversation_12346 は、文字列としてはかなり似ています。最後の1文字しか違いません。
でも、良いハッシュ関数を通すと、出力は近い数字にはなりません。全然違う場所へ飛びます。
この性質が、今回の用途にちょうど合います。
同じ会話IDは同じハッシュ値になる
連番の会話IDでも、ハッシュ後は散らばる
ハッシュ値をバケット数で割った余りを取れば、0 から N - 1 のバケットに変換できる
実務では、100個のバケットだけにすると少し粗いです。5%や25%だけなら問題ありませんが、0.1%や12.34%のような細かい割合を扱いにくくなります。
そこで、ここでは 100_000 個のバケットを使います。
100_000 バケット中 5_000 個を対象にすれば 5%
100_000 バケット中 25_000 個を対象にすれば 25%
100_000 バケット中 12_340 個を対象にすれば 12.34%
TypeScript で書くと、たとえばこうなります。
const BUCKET_COUNT = 100_000;
const encoder = new TextEncoder();
function fnv1a32(input: string): number {
let hash = 0x811c9dc5; // 2166136261
for (const byte of encoder.encode(input)) {
hash ^= byte;
hash = Math.imul(hash, 0x01000193); // 16777619
}
return hash >>> 0;
}
function bucketForRollout(rolloutKey: string, entityId: string): number {
return fnv1a32(`${rolloutKey}:${entityId}`) % BUCKET_COUNT;
}
function isInRollout(
rolloutKey: string,
entityId: string,
percent: number,
): boolean {
const threshold = Math.floor((percent / 100) * BUCKET_COUNT);
return bucketForRollout(rolloutKey, entityId) < threshold;
}
使う側はこうです。
const enabled = isInRollout(
"support-model-v2-shadow-mode",
[conversation.id](http://conversation.id),
5,
);
このコードでは、support-model-v2-shadow-mode と conversation.id の組み合わせからバケットを作っています。
conversation.id だけでなく rolloutKey も入れているのがポイントです。
rolloutKey を混ぜるのかもし会話IDだけをハッシュすると、どの機能でも同じ会話が「最初の5%」に入りやすくなります。
feature A の 5%: conversation_1, conversation_9, conversation_20, ...
feature B の 5%: conversation_1, conversation_9, conversation_20, ...
それが望ましい場合もあります。たとえば「常に同じ小さな検証用グループに流したい」なら、同じ選ばれ方でもよいかもしれません。
でも多くの場合、機能ごとに別の5%を選びたいはずです。ある機能の実験対象と、別の機能の実験対象が毎回同じになる必要はありません。
そこで、機能名や実験名を rolloutKey としてハッシュの入力に混ぜます。
"support-model-v2-shadow-mode:conversation_12345"
"new-routing-rule:conversation_12345"
同じ会話IDでも、rollout key が違えば別の入力文字列になります。結果として、機能ごとに別のバケットが作られます。
これは、乱数の世界でいう seed や salt に近い役割です。
もう1つ大事なのは、何のIDを使うかです。
今回の例では conversation.id を使っています。つまり、判定単位は「会話」です。
この場合、同じユーザーでも、ある会話は対象になり、別の会話は対象外になることがあります。
user_123 の conversation_A: 対象
user_123 の conversation_B: 対象外
会話ごとにモデル評価をしたいなら、それで問題ありません。
一方で、ユーザー体験が変わる機能なら、user.id で切る方が自然です。同じユーザーに対して、ある画面では新機能が出て、別の画面では出ない、という状態を避けられるからです。
isInRollout("new-inbox-ui", [user.id](http://user.id), 10);
どのIDを使うかは、技術的な細部ではなく、プロダクト上の判断です。
会話単位で一貫してほしいなら conversation.id
ユーザー単位で一貫してほしいなら user.id
企業やワークスペース単位で一貫してほしいなら workspace.id や account.id
サンプリングの単位を間違えると、コードは正しくても、実験やロールアウトとしては扱いづらくなります。
ここまで読むと、こう思うかもしれません。
「ハッシュなんて使わずに、IDの下2桁を見ればいいのでは?」
たとえば、会話IDが数値ならこういう方法です。
const bucket = Number(conversationId) % 100;
IDが完全にランダムなら、これでもある程度うまくいくかもしれません。
でも、実際のIDはランダムとは限りません。作成順に増える連番だったり、時刻やデータベースの内部構造を反映していたりします。
連番IDに対して単純に % 100 を使うと、作成順とバケットが強く結びつきます。
ID bucket
1000 0
1001 1
1002 2
1003 3
...
これでも長期的には均等に見えることがあります。ただし、短い時間帯だけを見ると偏ることがあります。たとえば、ある障害対応中に作られた会話だけ、特定のバケットに寄るかもしれません。
もっと悪いのは、「数字を足す」のような素朴な方法です。
会話ID 数字を足す
900000000000000 9
900000000000001 10
900000000000002 11
900000000000003 12
900000000000004 13
入力の規則性が、そのまま出力に残ります。
ハッシュを使う目的は、この規則性を壊すことです。
会話ID ハッシュ後のバケット
900000000000000 32
900000000000001 51
900000000000002 70
900000000000003 89
900000000000004 56
似たIDが、似たバケットに集まらないようにする。これがハッシュを使う理由です。
上のコードで使っている fnv1a32 は、FNV-1a という古くからある非暗号ハッシュです。
コードだけ見ると、少し不思議です。
hash ^= byte;
hash = Math.imul(hash, 0x01000193);
やっていることは、大きく分けると2つです。
hash ^= byte で、次の1バイトを現在の値に混ぜる
Math.imul(...) で、値を大きな定数倍してさらに散らす
これを入力の各バイトに対して繰り返します。
イメージとしては、1文字ずつ材料を入れながら、毎回よくかき混ぜるようなものです。
初期値
-> 1バイト目を混ぜる -> かき混ぜる
-> 2バイト目を混ぜる -> かき混ぜる
-> 3バイト目を混ぜる -> かき混ぜる
-> ...
最後に残った32ビットの数値を、バケット数で割った余りにします。
bucket = hash % BUCKET_COUNT;
FNV-1a は暗号用ではありません。パスワード保存、署名、改ざん検知のような用途には使えません。
ただし、今回のように「IDをそこそこ均等に散らして、安定した割合を選びたい」という用途には十分使いやすいです。
ここで一度、hash ^= byte を実際の数字で見ます。
まず、hash は「元のIDを数値にしたもの」ではありません。hash は、ここまで読んだ文字から作った 途中結果 です。
初期値
-> 1文字目を入れて更新した途中結果
-> 2文字目を入れて更新した途中結果
-> 3文字目を入れて更新した途中結果
-> ...
その途中結果に、次の1バイトを入れるために FNV-1a は XOR を使います。
^ は XOR というビット演算です。1ビットだけで見ると、ルールはこれだけです。
0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0
これだけだと分かりにくいので、10進数と2進数を並べます。
たとえば、途中結果の下位8ビットが 177 で、次に読む文字が "A" だとします。ASCII では "A" は 65 です。
hash = 177 = 10110001
byte = 65 = 01000001
--------
XOR = 240 = 11110000
177 ^ 65 は 240 になります。
ここで起きたことを、数字として見るとこうです。
64 の位は、hash では 0、byte では 1 だったので、1 に変わった
1 の位は、hash でも byte でも 1 だったので、0 に変わった
それ以外の位は、byte 側が 0 なので変わらない
つまり、+65 しているわけではありません。
足し算: 177 + 65 = 242
XOR : 177 ^ 65 = 240
同じ "A" を入れても、今の hash の状態によって、値が増えることも減ることもあります。
177 ^ 65 = 240 (増える)
241 ^ 65 = 176 (減る)
「反転する」というのは、最終的にビットを反転させたいからではありません。目的は、次の文字を読んだという事実で、途中結果を変えることです。
足し算なら、"A" を「65という数」として足します。XOR なら、"A" を「01000001 という0/1のパターン」として使い、途中結果の一部を切り替えます。
足し算: byte を数値として足す
XOR : byte を切り替えパターンとして使う
ただし、XOR だけではハッシュとして弱いです。
たとえば、途中結果を 177 として、"A" (65) と "B" (66) を XOR だけで入れてみます。
"A" then "B": 177 ^ 65 ^ 66 = 178
"B" then "A": 177 ^ 66 ^ 65 = 178
順番を入れ替えても同じ結果になります。XOR だけだと、同じ材料を入れたことは分かっても、順番が効きにくいのです。
そこで、FNV-1a は XOR の直後にかけ算します。小さな8ビットの例として、かける数を 5、範囲を 0〜255 として見ます。
開始値 = 177
"A" then "B":
177 ^ 65 = 240
240 * 5 = 176 (256で折り返した後)
176 ^ 66 = 242
242 * 5 = 186 (256で折り返した後)
"B" then "A":
177 ^ 66 = 243
243 * 5 = 191 (256で折り返した後)
191 ^ 65 = 254
254 * 5 = 246 (256で折り返した後)
XOR だけならどちらも 178 でした。でも、XOR のあとにかけ算を挟むと、"A" のあとに来た "B" と、"B" のあとに来た "A" で結果が変わります。
これが、FNV-1a の基本的な考え方です。
次の1バイトで途中結果を変える -> XOR
次の文字に備えて状態を動かす -> かけ算
実際の FNV-1a は8ビットではなく32ビットで、かける数も 5 ではなく 16,777,619 です。でも、やっていることの形は同じです。
では、XOR ではなく足し算や引き算ではダメなのでしょうか。
hash = Math.imul(hash, 0x01000193) + byte;
これはダメではありません。単に「次のバイトによって途中結果を変える」だけなら、足し算でも引き算でもできます。
hash = hash + byte;
hash = hash - byte;
hash = hash ^ byte;
どれも、次の byte によって hash を変える、という意味では同じです。
違うのは、どう変えるかです。
足し算: byte を数値として足す
引き算: byte を数値として引く
XOR : byte を0/1の切り替えパターンとして使う
hash = hash * K + byte という形でもハッシュは作れます。実際、そういう考え方のハッシュもあります。
ただし、それは FNV-1a ではなく、別のハッシュ設計です。
FNV-1a は、次のバイトを「数値として足す」のではなく、「0/1の切り替えパターンとして途中結果に入れる」設計です。そして、その直後のかけ算で状態を大きく動かします。
XOR が足し算や引き算より常に優れている、という話ではありません。XOR 単体が魔法なのでもありません。
大事なのは、FNV-1a では次の2つの役目が分かれていることです。
XOR: 次のバイトを使って、途中結果を変える
かけ算: 変わった途中結果を、次の文字に備えて動かす
もし XOR を足し算に置き換えたら、それは別のハッシュになります。悪いとは限りませんが、FNV-1a と同じ性質だとは言えません。連番IDで偏らないか、衝突しやすくないか、どれくらい散らばるかは、その別の設計として確認する必要があります。
では、かけ算だけでは足りないのでしょうか。
hash = Math.imul(hash, 0x01000193);
これだけだと、入力のバイトがどこにも入っていません。初期値が同じなら、同じ回数かけ算した結果になるだけです。極端に言えば、内容ではなく長さだけを見ているようなものになります。
では、IDが数値なら、元のIDに直接かけ算すればよいのでしょうか。
const bucket = Math.imul(Number(id), 0x01000193) % BUCKET_COUNT;
これも、十分なハッシュとは言いにくいです。
まず、IDは文字列かもしれません。conversation_12345 のように接頭辞が付いていることもあります。数値にできたとしても、JavaScript の Number で安全に表せる範囲を超えるIDもあります。
さらに、連番IDに定数をかけるだけだと、隣り合うID同士の関係が残ります。
id id * K のバケット
1000 ある場所
1001 そこから K 進んだ場所
1002 さらに K 進んだ場所
1003 さらに K 進んだ場所
順番を少し派手に並べ替えているだけで、「隣のIDは常に一定の距離だけ先へ行く」という構造は残ります。
ハッシュがやりたいのは、ID全体をバイト列として読み、各バイトを順番に途中結果へ入れ、そのたびに更新することです。そうすると、先頭の接頭辞も、末尾の数字も、途中の区切り文字も、すべて最終的な値に影響します。
ハッシュの中に出てくるこの行も、少し不思議に見えます。
hash = Math.imul(hash, 0x01000193);
0x01000193 は16進数で、10進数では 16,777,619 です。つまり、やっていることは「今の hash に 16,777,619 をかける」です。
ただし、目的は値をどんどん大きくすることではありません。
目的は、32ビットの範囲で折り返しながら、途中結果を別の場所へ動かすことです。
ここでも時計を思い浮かべると分かりやすいです。
普通の時計は12時間で1周します。
10時 + 5時間 = 3時
15時 になるのではなく、12で折り返して 3時 になります。
32ビット整数も同じです。2^32 個の目盛りを持つ巨大な時計だと思えます。計算結果が一周を超えたら、余った場所に戻ります。
小さな8ビットの例で見ると、こうです。8ビットは 0〜255 までなので、256で折り返します。
240 * 5 = 1200
1200 を 256 で割った余り = 176
だから、8ビットの世界では 240 * 5 の結果は 1200 ではなく 176 になります。
240 -> 176
値を大きくするのではなく、時計の別の位置へ移動している、と見る方が近いです。
実際の FNV-1a では、これを8ビットではなく32ビットで行います。
hash * 16,777,619
ただし 2^32 で折り返す
ここで Math.imul が出てきます。
JavaScript の普通の数値は Number で、内部的には浮動小数点数です。普通に * を使うと、巨大な整数の下位ビットを正確に保てないことがあります。
今回のかけ算はかなり大きくなります。
最大でだいたい:
4,294,967,295 * 16,777,619
≈ 72,000,000,000,000,000
これは JavaScript の Number が整数として安全に扱える範囲を超えます。
でも FNV-1a で欲しいのは、巨大な正確な積そのものではありません。欲しいのは、32ビットで折り返した後の下位32ビットです。
Math.imul(a, b) は、そのための関数です。
a と b を32ビット整数として扱う
32ビット整数のかけ算をする
結果の下位32ビットを返す
つまり、FNV-1a の「32ビットで折り返すかけ算」を JavaScript で正しく書くために Math.imul を使っています。
hash = Math.imul(hash, 0x01000193);
これはだいたい次の意味です。
hash = (hash * 16,777,619) を 2^32 で折り返した値
もう1つ、最後のこの行も関係しています。
return hash >>> 0;
Math.imul の返り値は、JavaScript では「符号付き32ビット整数」として見えます。そのため、同じ32個のビットでも、数値としてはマイナスに見えることがあります。
Math.imul(0xffffffff, 5); // -5
Math.imul(0xffffffff, 5) >>> 0; // 4294967291
ビット列は同じです。>>> 0 は、その32ビットを「符号なし整数」として読み直すための処理です。最後に % BUCKET_COUNT する前に、普通の正の数として扱いたいので入れています。
ここで、かける数が偶数だと、下位ビットに偏りが残りやすくなります。FNV-1a が使う 16777619 は奇数です。2^32 は2だけを因数に持つ数なので、奇数とは共通の因数を持ちません。
この性質のおかげで、単純に一部の場所だけを行き来するような潰れ方を避けられます。
ただし、ここで大事なのは「奇数だから完全に均等になる」という単純な話ではありません。
FNV-1a の分散は、各バイトを XOR で混ぜ、定数倍し、32ビットで折り返す処理を繰り返すことで得られます。定数 16777619 は、その用途でよく使われてきた FNV の prime です。
この記事の目的では、細かい証明まで理解する必要はありません。覚えておけばよいのは次です。
入力IDには規則性があることが多い
ハッシュはその規則性を崩して、値を広い範囲に散らす
その値をバケットに落とすと、安定したサンプリングに使える
ここは言葉として大事です。
この方法は、常に ちょうど5% を選ぶわけではありません。選ぶのは 約5% です。
たとえば会話が17件しかないとき、5%は0.85件です。会話を0.85件だけ選ぶことはできません。実際には0件、1件、2件のように整数になります。
会話数が少ないほど、実際の割合はぶれます。
一方で、会話数が増えるほど、割合は指定値に近づきます。100,000件あれば、だいたい5,000件前後になります。
また、hash % BUCKET_COUNT にはごく小さな偏りがあります。2^32 が 100_000 で割り切れないため、理論上は一部のバケットがほんの少しだけ出やすくなります。
ただし、その差は32ビット空間全体で見て極めて小さいので、通常のロールアウトやサンプリングでは問題になりません。
本当に「全体から正確に5%だけ」を選びたいなら、全対象を一度並べて、上位5%を選ぶような処理が必要です。その場合は、対象全体を知っている必要があります。
ここで紹介している方法の強みは、正確な件数管理ではなく、次の性質です。
各リクエストでその場で判定できる
保存がいらない
同じIDは毎回同じ結果になる
割合を上げると対象が単調に増える
実務の段階的ロールアウトでは、この性質の方が重要なことが多いです。
この方法は、特別に新しい技術ではありません。Feature flag、A/B テスト、段階的ロールアウトではよく使われる考え方です。
ただ、初めて見ると少し不思議に見えます。
hash(id) % 100_000 < threshold
たったこれだけで、次のことが同時にできます。
全体の約5%を選ぶ
同じ会話を毎回同じ判定にする
5%から25%に増やしても、既存の対象を外さない
会話ごとの選択結果を保存しない
ポイントは、ランダムに「その場で選ぶ」のではなく、IDから「安定した位置」を作ることです。
会話IDをハッシュしてバケットに入れる。割合はしきい値として扱う。しきい値を右に動かすと、対象が少しずつ増える。
段階的ロールアウトのサンプリングは、この考え方でかなり扱いやすくなります。