C# でメッセージごとに一意な 10〜16 桁の数値 ID を発行したいのに、「RandomNumberGenerator.GetInt32 が使えない」「分散構成で衝突が不安」といった悩みは非常によくあります。この記事では、UNIX ミリ秒をベースにした実用的な ID 設計パターンと、古い .NET 環境でも使える実装例を、サンプルコードとともに丁寧に整理します。
C# で「10〜16桁・一意な数値ID」を設計するときの前提
まず、要件を整理します。単にランダムな数字を作るだけでなく、どこまでの一意性が必要か、どの環境で動かしたいかが重要です。
| 観点 | 典型的な要件 | 設計への影響 |
|---|---|---|
| 桁数 | 10〜16 桁の 十進数のみ | 64bit 整数(long)で扱える範囲に収まるので、算術で合成できる |
| 一意性 | 「プロセス内」「サーバー内」「分散システム全体」のどこまで一意? | 用途により、カウンタだけでよいか、時刻+連番+ノードIDが必要かが変わる |
| 有効期間 | 実行中だけ一意でよいのか、永続データとしても一意であるべきか | 再起動やデプロイをまたいで重複したくない場合は、時刻情報を混ぜるのが有利 |
| 分散構成 | 単一プロセス / 複数プロセス / 複数サーバー | 中央採番をするか、ノードIDを組み込むかなどの設計が必要 |
| 性能 | 1 秒あたり何件ぐらい発行するか | 同一ミリ秒内の最大発行数から、必要な連番桁数を決める |
| 利用環境 | .NET Framework / .NET Core / Unity など | RandomNumberGenerator.GetInt32 が使えない環境に配慮した実装が必要 |
この記事では、これらの前提を踏まえ、次の 4 パターンに整理して解説します。
- アトミックカウンタ(単一プロセス向け)
- 時刻+サフィックス(ランダム or 連番)
- GUID を 16 桁以内の数値に畳み込む方法
- DB / Redis などによる中央採番
UNIXミリ秒をベースにする理由
10〜16 桁という制約の下で一意性と扱いやすさを両立させるには、ID の上位桁に時刻情報を入れるのが非常に有効です。C# では、次のようにして「UNIX 時刻(ミリ秒)」が簡単に取得できます。
long ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // 例: 1713320485123 (13桁)
- 西暦 1970/1/1 からの経過ミリ秒
- 現在時点(2020〜2030 年代)では 13 桁の十進数
- 基本的には「時間が進むほど値も増える」
この 13 桁をベースにしつつ、下位に連番やランダムを足すと、16 桁以内で設計しやすくなります。
パターンA:単一プロセス向けアトミックカウンタ(13桁)
もっともシンプルで高速な方法が、「プロセス内だけで一意ならよい」と割り切るアトミックカウンタ方式です。
基本実装例
using System;
using System.Threading;
public static class MessageIdGeneratorA
{
// プロセス起動時の UNIX ミリ秒を初期値にする
private static long _id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
public static long NextId() => Interlocked.Increment(ref _id); // ほぼ 13桁
}
- マルチスレッドでも
Interlocked.Incrementにより衝突なし - プロセスが落ちるまで ID は単調増加
- 再起動すると初期値が変わるため、古い実行と新しい実行で ID が重なる可能性がある
メリット・デメリット
| 項目 | 内容 |
|---|---|
| メリット | 実装が極めて簡単・高速。単一プロセス内での一意性は保証される。 |
| デメリット | プロセス再起動や複数プロセス/複数サーバー間では重複する可能性がある。 |
| 向いている用途 | 短命なバックグラウンド処理、バッチ内の仮 ID、ログ行の一時識別子など。 |
もし「アプリ全体として絶対に被ってほしくない」「分散構成で動く」のであれば、次のパターン B を検討するべきです。
パターンB:時刻+サフィックスで分散・再起動にも強くする
UNIX ミリ秒 13 桁に、下位数桁のサフィックスを足して 15〜16 桁の ID を作る方式です。
- 上位 13 桁:時刻(UNIX ミリ秒)
- 下位 2〜3 桁:ランダム値または連番
「再起動しても」「複数ノードが同時に発行しても」衝突しづらくなり、現実的な ID としてバランスが良い構成です。
B-1:時刻+ランダムサフィックス(低衝突・15〜16桁)
まずは実装が簡単な「時刻 + ランダム」から見ていきます。
RandomNumberGenerator.GetInt32 が使えない環境への対応
.NET 5+ などでは、次のようにして簡単にランダムな整数が取れます。
using System.Security.Cryptography;
int rnd = RandomNumberGenerator.GetInt32(0, 1000); // 0〜999
しかし、古い .NET Framework や一部の Unity 環境では、このメソッドが存在せずコンパイル エラー(CS0117)になります。その場合は、Create().GetBytes() を使って自前で実装すれば問題ありません。
GetInt32 非対応環境向けランダム 3 桁実装
using System;
using System.Security.Cryptography;
public static class LegacyRandom
{
private static readonly RandomNumberGenerator _rng
= RandomNumberGenerator.Create();
// 0〜999 の整数を返す(000〜999 を表現)
public static int Random3Digits()
{
var b = new byte[4];
_rng.GetBytes(b);
int value = BitConverter.ToInt32(b, 0);
if (value == int.MinValue) value = 0; // Abs の例外パターンを潰す
return Math.Abs(value) % 1000; // 0〜999
}
}
16桁 ID の生成例(13桁の時刻+3桁ランダム)
using System;
public static class MessageIdGeneratorB1
{
public static long NextId16()
{
long ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // 13桁
int rnd = LegacyRandom.Random3Digits(); // 000〜999
long id = ms * 1000L + rnd; // 最大 16桁
return id;
}
// 固定 16 桁の文字列が欲しい場合
public static string NextId16String()
{
return NextId16().ToString("D16"); // 左ゼロ埋めで 16 桁固定
}
}
B-1 の特徴
| 項目 | 内容 |
|---|---|
| 一意性 | 同じミリ秒内に多数発行すると、理論上は衝突する可能性がある(ただしかなり低い)。 |
| メリット | 実装がシンプル。分散環境でも衝突確率は極めて低い。古い環境でも実装可能。 |
| デメリット | 「絶対に衝突させたくない」といった厳格な要件には向かない。 |
| 向いている用途 | ログID、トレースID、UI 表示用のメッセージ ID など、多少の衝突許容がある用途。 |
「衝突する確率は限りなく小さくてよいがゼロではない」レベルでよければ、B-1 は総じてバランスがよく、実装コストも低いです。
B-2:時刻+連番サフィックス(16桁固定・ノード内衝突ゼロ)
より厳密な一意性が欲しい場合は、ランダムではなく連番を使うのが有効です。同じミリ秒内で 0〜999 を振れば、次のような構成になります。
- 上位 13 桁:UNIX ミリ秒
- 下位 3 桁:同じミリ秒内での連番(0〜999)
この場合、1 ノードあたり 1 ミリ秒につき最大 1000 件まで完全に衝突ゼロで ID を発行できます。
基本実装(単一ノード向け)
using System;
public static class MessageIdGeneratorB2
{
private static readonly object _lock = new();
private static long _lastMs;
private static int _seq; // 0..999
public static long NextId16()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (_lock)
{
// 時計が逆行した場合は直近の値に追いつかせる
if (now < _lastMs)
{
now = _lastMs;
}
if (now == _lastMs)
{
_seq++;
if (_seq >= 1000)
{
// 同一 ms で 1000 件を超えたら、次の ms まで待つパターン
// 待ちたくない場合は %1000 でループさせる設計もあり
do
{
now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
while (now <= _lastMs);
_lastMs = now;
_seq = 0;
}
}
else
{
_lastMs = now;
_seq = 0;
}
return now * 1000L + _seq; // 16桁(13桁+3桁)
}
}
public static string NextId16String() => NextId16().ToString("D16");
}
この実装では、
- 同一ミリ秒内で 1000 件を超えた場合、次のミリ秒まで待つことで衝突を 0 に保つ
- 時計が逆行した場合(NTP 調整など)も、
_lastMsを下回らないように調整
という工夫を入れています。
複数ノード対応:連番の一部をノードIDに割り当てる
複数サーバーで ID を発行する場合、ノード ID を 1 桁(0〜9)割り当てて、残り 2 桁を連番にする、といった設計も可能です。
- 下位 3 桁の内訳:
nodeId (0〜9)× 100 +seq (0〜99) - 各ノードは、同じミリ秒内で最大 100 件まで連番を発行可能
using System;
public sealed class MessageIdGeneratorNode
{
private static readonly object _lock = new();
private long _lastMs;
private int _seq; // 0..99
private readonly int _nodeId; // 0..9
public MessageIdGeneratorNode(int nodeId)
{
if (nodeId < 0 || nodeId > 9)
throw new ArgumentOutOfRangeException(nameof(nodeId), "nodeId は 0〜9 で指定してください。");
_nodeId = nodeId;
}
public long NextId16()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (_lock)
{
if (now < _lastMs)
{
now = _lastMs;
}
if (now == _lastMs)
{
_seq++;
if (_seq >= 100)
{
// 同一 ms で 100 件を超えたら次の ms まで待つ
do
{
now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
} while (now <= _lastMs);
_lastMs = now;
_seq = 0;
}
}
else
{
_lastMs = now;
_seq = 0;
}
int suffix = _nodeId * 100 + _seq; // 下位 3 桁
return now * 1000L + suffix; // 最大 16桁
}
}
public string NextId16String() => NextId16().ToString("D16");
}
B-2 の特徴
| 項目 | 内容 |
|---|---|
| 一意性 | ノード単位では完全に衝突ゼロ。ノード ID を適切に振ればクラスタ全体でも実用上衝突ゼロ。 |
| メリット | 時刻ベースでソートしやすく、ログとの突き合わせもしやすい。連番のおかげで ID の順序が分かりやすい。 |
| デメリット | 実装がやや複雑。1 ミリ秒あたりの最大発行件数に上限がある(設計で調整可能)。 |
| 向いている用途 | メッセージキュー ID、イベントストアの ID、分散マイクロサービスのメッセージ ID など。 |
「迷ったら B-2(時刻+連番の 16 桁)」と言えるほど、実用面でのバランスが良い方式です。
パターンC:GUIDを16桁以内の数値に畳み込む
Windows / .NET のお家芸ともいえる GUID(128bit)を使う方法もあります。GUID 自体は 32 桁の 16 進文字列ですが、これを long に畳み込んで 16 桁以内の数値にすることもできます。
実装例
using System;
public static class MessageIdGeneratorC
{
public static long NextId16()
{
var bytes = Guid.NewGuid().ToByteArray();
long raw = BitConverter.ToInt64(bytes, 0);
if (raw == long.MinValue) raw = 0;
long value = Math.Abs(raw) % 1_0000_0000_0000_0000L; // 10^16
return value; // 0〜 9999 9999 9999 9999
}
public static string NextId16String() => NextId16().ToString("D16");
}
GUIDベース方式の性質
- GUID はもともと 128bit と非常に巨大な空間を持つため、元の GUID 同士の衝突はほぼ無視できる
- ただし、
% 10^16で「剰余」にしているため、理論上の一意性は元 GUID より低くなる - とはいえ、一般的な業務アプリのスケールでは十分な一意性を持つことが多い
| 項目 | 内容 |
|---|---|
| メリット | 時刻や連番を考えず実装できる。分散環境でも衝突確率は極めて小さい。 |
| デメリット | 時刻順に並ばない(ID から生成時刻が分からない)、剰余のため理論上の一意性は少し落ちる。 |
| 向いている用途 | 時間順序が不要な ID、オフライン生成、クライアントサイドでの仮 ID など。 |
「とにかく簡単に 16 桁の数値 ID がほしい、時間順は要らない」という場合には現実的な選択肢です。
パターンD:DB / Redisなどによる中央採番
もっとも厳格で分かりやすい方法は、「ID の採番をどこか一箇所に集中させる」ことです。代表的なのは次のようなパターンです。
- RDB の IDENTITY / SEQUENCE を利用
- Redis の INCR / INCRBY を利用
典型例:RDBのSEQUENCEを利用する
DB に SEQUENCE(または IDENTITY)を一つ用意し、それをアプリから呼び出して ID をもらう方式です。DB が一意性を保証してくれます。
// 例: Dapper を使ったシンプルな取得例(擬似コード)
public async Task<long> NextIdFromSequenceAsync(IDbConnection conn)
{
// DB種別に応じて SQL は適宜変更
const string sql = "SELECT NEXT VALUE FOR MessageIdSequence;";
return await conn.ExecuteScalarAsync<long>(sql);
}
public async Task<string> NextId16StringAsync(IDbConnection conn)
{
long id = await NextIdFromSequenceAsync(conn);
return id.ToString("D16"); // 必要に応じて 16桁にゼロ埋め
}
Redis であれば、INCR コマンドを使うことで、分散環境でも単調増加のカウンタを簡単に実現できます。
中央採番方式の特徴
| 項目 | 内容 |
|---|---|
| 一意性 | 採番元が一箇所であれば、分散環境でも事実上一意。 |
| メリット | 実装がわかりやすく、一意性も説明しやすい。監査ログや帳票番号などと相性が良い。 |
| デメリット | 採番サーバーがボトルネックになり得る。ネットワーク越しの呼び出しコストが発生。 |
| 向いている用途 | 会計番号、伝票番号、監査ログ ID など、「番号の飛び」や一意性が厳しく監査される場面。 |
なお、Snowflake 型の ID(時刻+ノードID+連番)をフル 64bit で実装すると 19 桁になるのが一般的です。10〜16 桁に制約される場合は、この記事の B-2 のようにビット数を絞った設計にする方が現実的です。
各方式の比較と選び方
ここまで紹介した A〜D を一覧で比較します。
| 方式 | 典型桁数 | 一意性の範囲 | 分散対応 | 実装コスト | 代表的な用途 |
|---|---|---|---|---|---|
| A: アトミックカウンタ | 約 13 桁 | 単一プロセス内のみ | 考慮なし | 最小 | 短命バッチ、デバッグ、ログ用一時 ID |
| B-1: 時刻+ランダム | 15〜16 桁 | 理論上は衝突あり | 比較的強い | 小 | トレース ID、ログ ID、UI 表示 ID |
| B-2: 時刻+連番 | 16 桁固定 | ノード単位で衝突ゼロ | ノードID 付与で対応可 | 中 | メッセージ ID、イベント ID、ジョブ ID |
| C: GUID 畳み込み | 最大 16 桁 | 理論上は衝突あり | 強い | 小 | オフライン ID、クライアント側仮 ID |
| D: 中央採番 | 任意(ゼロ埋めで調整) | システム全体で一意 | 採番サーバーを共有 | 中〜大 | 伝票番号、会計番号、厳格な ID |
選び方の早見表
- 単一プロセスだけで完結するなら:パターン A
- ノード内で絶対に重複させたくない 16 桁 IDが欲しいなら:パターン B-2
- 軽量で分散・低衝突で十分なら:パターン B-1
- 監査要件が厳しく、中央管理したいなら:パターン D
- 時間順はいらないが簡単さ重視なら:パターン C
実装のコツとアンチパターン
時計の逆行(NTP調整)への対策
サーバー時刻は NTP による調整等で一時的に逆行する場合があります。時刻を基準に ID を作る場合、単調増加を守るために次のような対策を入れておくと安心です。
if (now < _lastMs)
{
now = _lastMs; // 逆行した場合は直近の値を採用
}
これにより、少なくとも ID の上位の時刻部分は常に非減少となります。
同一ミリ秒内での大量発行
1 ミリ秒内にどれくらい発行されるかを見積もり、必要な連番桁数を決めましょう。
| 連番桁数 | 表現できる値 | 1msあたりの最大発行件数 | 例 |
|---|---|---|---|
| 1桁 | 0〜9 | 10件 | ごく小規模なアプリ |
| 2桁 | 0〜99 | 100件 | 軽量な Web API |
| 3桁 | 0〜999 | 1000件 | 多くの業務システムは十分 |
発行頻度がそれほど高くないなら 2〜3 桁で十分なことが多いです。10〜16 桁という制約がある場合、上位は 13 桁の時刻にして、下位で 2〜3 桁を連番にするのが現実的です。
文字列結合ではなく算術合成を使う
ID を「文字列の結合」で作ると、パフォーマンスや GC 負荷の面でやや不利です。数値 ID を作るときは、以下のように掛け算+足し算で合成しましょう。
// NG 例:文字列で合成
string idStr = ms.ToString() + suffix.ToString("D3");
// OK 例:数値で合成
long id = ms * 1000L + suffix;
最終的に文字列で扱いたい場合も、合成は数値で行い、最後に ToString("D16") などでフォーマットする方が効率的です。
10桁など、さらに短いIDが必要な場合
要件によっては「10 桁にしたい」といったケースもありますが、短くするほど衝突リスクは高まります。
- 時刻部分を「秒」や「10ms」単位にして桁数を削る
- 一部の桁を省略する(ただし有効期間を想定して設計する必要あり)
例えば「1 日限りの ID でよい」など有効期間が短いのであれば、秒単位の時刻+数桁の連番で 10 桁程度に収める、といった工夫も可能です。ただし、この記事の主題は 13〜16 桁周辺なので、長期運用を考えるなら 16 桁設計をおすすめします。
セキュリティ用途には使わない
ここで紹介している ID は、「一意に識別する」ことを目的にした設計です。推測可能であるため、
- ワンタイムパスワード
- パスワードリセット用トークン
- 認証・認可に関わるトークン
などのセキュリティ用途には絶対に流用しないでください。そういった用途には、暗号論的に安全なランダム値(32 バイト程度)を Base64 などで表現する方式を使うべきです。
サンプル:実運用向けメッセージID生成サービスクラス
最後に、実運用でも使いやすい「時刻+連番 (B-2)」ベースの ID 生成クラスの例を載せておきます。依存注入(DI)で使い回すことを想定した設計です。
using System;
public interface IMessageIdGenerator
{
long NextId();
string NextIdString();
}
public sealed class MillisSequenceMessageIdGenerator : IMessageIdGenerator
{
private static readonly object _lock = new();
private long _lastMs;
private int _seq; // 0〜999
private readonly int _nodeId; // 0〜9
public MillisSequenceMessageIdGenerator(int nodeId = 0)
{
if (nodeId < 0 || nodeId > 9)
throw new ArgumentOutOfRangeException(nameof(nodeId), "nodeId は 0〜9 で指定してください。");
_nodeId = nodeId;
}
public long NextId()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (_lock)
{
if (now < _lastMs)
{
now = _lastMs;
}
if (now == _lastMs)
{
_seq++;
if (_seq >= 100)
{
// 同一 ms で 100 件を超えたら次の ms まで待つ
do
{
now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
} while (now <= _lastMs);
_lastMs = now;
_seq = 0;
}
}
else
{
_lastMs = now;
_seq = 0;
}
int suffix = _nodeId * 100 + _seq; // 下位 3 桁
long id = now * 1000L + suffix; // 16桁 ID
return id;
}
}
public string NextIdString()
{
return NextId().ToString("D16");
}
}
利用例
public class MessageSender
{
private readonly IMessageIdGenerator _idGenerator;
public MessageSender(IMessageIdGenerator idGenerator)
{
_idGenerator = idGenerator;
}
public void Send(string payload)
{
long id = _idGenerator.NextId();
string idStr = _idGenerator.NextIdString();
// ログ用
Console.WriteLine($"Sending message {idStr}: {payload}");
// メッセージに ID を付与して送信…など
}
}
ノードごとに異なる nodeId で MillisSequenceMessageIdGenerator を生成して DI コンテナに登録しておけば、クラスタ構成でも ID の衝突を実用上避けることができます。
簡易テストで衝突をチェックする
実際に導入する前に、簡単な負荷テストで「同じプロセス内で ID が重複しないか」を確認しておくと安心です。
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
public static class CollisionTest
{
public static void Run()
{
var gen = new MillisSequenceMessageIdGenerator(nodeId: 1);
var bag = new ConcurrentBag<long>();
Parallel.For(0, 1_000_000, i =>
{
bag.Add(gen.NextId());
});
var distinctCount = bag.Distinct().Count();
Console.WriteLine($"Generated: {bag.Count}, Distinct: {distinctCount}");
}
}
実行して Generated と Distinct の値が一致していれば、その範囲で重複は発生していません。あくまで「検証用」ですが、連番方式では衝突は起こらないはずです。
まとめ
C# で「10〜16 桁の十進数で、実行時に重複しないメッセージ ID」を設計する際のポイントを整理しました。
- 上位 13 桁に UNIX ミリ秒を使うと、設計がシンプルになり時間順ソートも容易。
- 単一プロセスで済むなら、アトミックカウンタ(パターン A) がもっとも簡単。
- 分散・再起動にも強い ID が欲しいなら、時刻+連番(パターン B-2) が非常にバランスが良い。
RandomNumberGenerator.GetInt32が使えない環境では、Create().GetBytes()で代替可能。- GUID を 16 桁以内に畳み込む(パターン C)や、DB / Redis で中央採番(パターン D)という選択肢も用途によっては有効。
特に、メッセージ ID のように「時間順に並べたい」「分散構成でも安全に使いたい」ケースでは、UNIX ミリ秒 13 桁+3 桁連番の 16 桁 ID は実運用でも非常に扱いやすいパターンです。この記事のサンプルコードをベースに、自身のシステムの要件に合わせて桁数やノード ID の割り当てを調整してみてください。

コメント