C#で10〜16桁の一意な数値メッセージIDを生成する方法|RandomNumberGenerator非対応環境も解説

C# でメッセージごとに一意な 10〜16 桁の数値 ID を発行したいのに、「RandomNumberGenerator.GetInt32 が使えない」「分散構成で衝突が不安」といった悩みは非常によくあります。この記事では、UNIX ミリ秒をベースにした実用的な ID 設計パターンと、古い .NET 環境でも使える実装例を、サンプルコードとともに丁寧に整理します。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

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 &lt; 0 || nodeId &gt; 9)
            throw new ArgumentOutOfRangeException(nameof(nodeId), "nodeId は 0〜9 で指定してください。");

        _nodeId = nodeId;
    }

    public long NextId16()
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

        lock (_lock)
        {
            if (now &lt; _lastMs)
            {
                now = _lastMs;
            }

            if (now == _lastMs)
            {
                _seq++;
                if (_seq &gt;= 100)
                {
                    // 同一 ms で 100 件を超えたら次の ms まで待つ
                    do
                    {
                        now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
                    } while (now &lt;= _lastMs);

                    _lastMs = now;
                    _seq = 0;
                }
            }
            else
            {
                _lastMs = now;
                _seq = 0;
            }

            int suffix = _nodeId * 100 + _seq;  // 下位 3 桁
            return now * 1000L + suffix;        // 最大 16桁
        }
    }

    public string NextId16String() =&gt; 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&lt;long&gt; NextIdFromSequenceAsync(IDbConnection conn)
{
    // DB種別に応じて SQL は適宜変更
    const string sql = "SELECT NEXT VALUE FOR MessageIdSequence;";
    return await conn.ExecuteScalarAsync&lt;long&gt;(sql);
}

public async Task&lt;string&gt; 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 &lt; _lastMs)
{
    now = _lastMs; // 逆行した場合は直近の値を採用
}

これにより、少なくとも ID の上位の時刻部分は常に非減少となります。

同一ミリ秒内での大量発行

1 ミリ秒内にどれくらい発行されるかを見積もり、必要な連番桁数を決めましょう。

連番桁数表現できる値1msあたりの最大発行件数
1桁0〜910件ごく小規模なアプリ
2桁0〜99100件軽量な Web API
3桁0〜9991000件多くの業務システムは十分

発行頻度がそれほど高くないなら 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 &lt; 0 || nodeId &gt; 9)
            throw new ArgumentOutOfRangeException(nameof(nodeId), "nodeId は 0〜9 で指定してください。");

        _nodeId = nodeId;
    }

    public long NextId()
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

        lock (_lock)
        {
            if (now &lt; _lastMs)
            {
                now = _lastMs;
            }

            if (now == _lastMs)
            {
                _seq++;
                if (_seq &gt;= 100)
                {
                    // 同一 ms で 100 件を超えたら次の ms まで待つ
                    do
                    {
                        now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
                    } while (now &lt;= _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 を付与して送信…など
}


} 

ノードごとに異なる nodeIdMillisSequenceMessageIdGenerator を生成して 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&lt;long&gt;();

        Parallel.For(0, 1_000_000, i =&gt;
        {
            bag.Add(gen.NextId());
        });

        var distinctCount = bag.Distinct().Count();
        Console.WriteLine($"Generated: {bag.Count}, Distinct: {distinctCount}");
    }
}

実行して GeneratedDistinct の値が一致していれば、その範囲で重複は発生していません。あくまで「検証用」ですが、連番方式では衝突は起こらないはずです。

まとめ

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 の割り当てを調整してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次