採番設計とは、データに付ける番号を「どの方式で」「どんな体系で」発番するかを決めることです。 方式は大きく、シーケンス、IDENTITY/AUTO_INCREMENT、採番テーブル、UUIDの4つがあり、「欠番を許せるか」「年月や拠点ごとに1から振り直すか」「同時に何件発番するか」の3点で選び分けます。

この記事では、4方式の比較表と選び方を示したうえで、採番テーブル方式をPostgreSQLとMySQLの両方で書き、2つのセッションから同時に実行したときの番号の重複、行ロックでの解消、ロールバックしたときの欠番を実行結果で確かめます。後半には、接頭辞・年月・連番・チェックディジットからなる伝票番号体系のテンプレートと、桁あふれの時期を求める計算例を載せます。

確認環境:Ubuntu 24.04、PostgreSQL 16.15、MySQL 8.0.46(InnoDB)、Python 3.11.15(psycopg2 2.9.13、PyMySQL 2.2.8)。UUIDv7 の生成だけ Python 3.14.7 で実行しています。

採番方式の早見表

比べる点 シーケンス IDENTITY/AUTO_INCREMENT 採番テーブル UUIDv7
仕組み 番号を出す専用のオブジェクト(PostgreSQL) 列に付ける自動連番(PostgreSQLのIDENTITY、MySQLのAUTO_INCREMENT) 「最後の番号」を持つ行を自分で更新する 時刻と乱数から作る128ビットの値
欠番 出る(ロールバックした分は戻らない) 出る(同じ) 出ない(ロールバックで番号も戻る) 連番ではないので欠番という考え方がない
連続性 増える順だが、とびとびになりうる 同じ 1ずつ連続 生成した時刻の順に並ぶ
同時実行 互いに待たない 互いに待たない 同じキーの採番は1件ずつ(後の処理は前のコミットを待つ) データベースに問い合わせずに作れる
年月ごとのリセット 切り替え時に RESTART を実行する PostgreSQLは RESTART。MySQLは現在の最大値より小さくは戻らない キーに年月を入れるだけ 対象外
拠点ごとの連番 拠点の数だけシーケンスを作る 1テーブルに1つなので向かない キーに拠点を入れるだけ 対象外
向く用途 内部の主キー 内部の主キー 伝票番号など、人が見る連番 複数のシステムやアプリ側で発番するID

迷ったら、内部の主キーはIDENTITY/AUTO_INCREMENT、人が見る番号は採番テーブル、と分けて考えると決めやすくなります。1つの番号に両方の役目を持たせず、主キーと伝票番号を別の列にする設計です。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「採番設計の方法|シーケンス・採番テーブル・UUIDの比較表とSQL例」

方式を選ぶ判断表

上から順に当てはめます。

問い はい いいえ
1. 番号を画面や帳票で人が見るか、番号に年月や拠点の意味を持たせるか 採番テーブル(下の問い4へ) 問い2へ
2. データベースに登録する前にIDが必要か、複数のデータベースで別々に発番するか UUIDv7 問い3へ
3. 行を登録するときに番号が決まればよいか IDENTITY/AUTO_INCREMENT シーケンス(登録の前に番号を取り出せる)
4. 番号がとんでいてもよいか シーケンスを連番部分に使う方法も選べる 採番テーブルで、本体の登録と同じトランザクションで採番する

先に決めておく5つのこと

決めること 例 影響するところ
欠番を許すか 許さない/理由が追えればよい/気にしない 方式、取り消しの扱い
番号の単位 全体で1本、拠点ごと、伝票の種類ごと 採番テーブルのキー
リセットの周期 なし、年度、月、日 キーに入れる日付、桁数
桁数と上限 連番5桁(月99,999件まで) 桁あふれの時期
取り消した番号の扱い 再利用しない。取消の行として残す 欠番の説明、検索

シーケンスとIDENTITY/AUTO_INCREMENTはロールバックで欠番になる

まず、番号を自動で振る方式の性質を確かめます。登録をロールバックしても、使った番号は戻りません。

PostgreSQL(シーケンスとIDENTITY)

CREATE SEQUENCE order_seq;
CREATE TABLE orders_seq (id bigint PRIMARY KEY DEFAULT nextval('order_seq'), memo text);
CREATE TABLE orders_idt (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, memo text);

INSERT INTO orders_seq (memo) VALUES ('1件目');
INSERT INTO orders_idt (memo) VALUES ('1件目');
BEGIN;
INSERT INTO orders_seq (memo) VALUES ('取り消す行');
INSERT INTO orders_idt (memo) VALUES ('取り消す行');
ROLLBACK;
INSERT INTO orders_seq (memo) VALUES ('2件目');
INSERT INTO orders_idt (memo) VALUES ('2件目');
SELECT * FROM orders_seq;
SELECT * FROM orders_idt;
 id | memo  
----+-------
  1 | 1件目
  3 | 2件目
(2 rows)

 id | memo  
----+-------
  1 | 1件目
  3 | 2件目
(2 rows)

どちらも 2 がとんで、1 の次が 3 になりました。PostgreSQLの公式ドキュメント(Sequence Manipulation Functions)には、同じシーケンスから番号を取る他のトランザクションを待たせないために、nextval で取った値は呼び出したトランザクションが中断しても再利用されないこと、そのためシーケンスは「欠番のない」連番を得る目的には使えないことが書かれています。待たせない代わりに欠番を許す、という設計です。

MySQL(AUTO_INCREMENT)

CREATE TABLE orders_ai (id bigint AUTO_INCREMENT PRIMARY KEY, memo varchar(20));
INSERT INTO orders_ai (memo) VALUES ('1件目');
START TRANSACTION;
INSERT INTO orders_ai (memo) VALUES ('取り消す行');
ROLLBACK;
INSERT INTO orders_ai (memo) VALUES ('2件目');
SELECT * FROM orders_ai;
+----+---------+
| id | memo    |
+----+---------+
|  1 | 1件目   |
|  3 | 2件目   |
+----+---------+

MySQLでも同じ結果です。MySQL 8.0 リファレンスマニュアル(InnoDB AUTO_INCREMENT Handling)にも、自動採番の値を生成したトランザクションがロールバックするとその値は失われて再利用されず、AUTO_INCREMENT 列の値にすき間ができうる、と書かれています。

年度の切り替えで1に戻す

PostgreSQLのシーケンスは RESTART で好きな値に戻せます。

SELECT nextval('order_seq');
ALTER SEQUENCE order_seq RESTART WITH 1;
SELECT nextval('order_seq');
 nextval 
---------
       4
(1 row)

ALTER SEQUENCE
 nextval 
---------
       1
(1 row)

IDENTITY列は ALTER TABLE orders_idt ALTER COLUMN id RESTART WITH 100 のように書き、次の登録で 100 が振られることを確かめました。MySQLで、id が 3 まで入っているテーブルに ALTER TABLE orders_ai AUTO_INCREMENT = 1 を実行したあと SHOW CREATE TABLE で見ると、次の値は 4 のままでした。

) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

リセットは「切り替えの時点で誰かが実行する」作業になり、拠点ごとに分けるにはシーケンスを拠点の数だけ用意します。周期や単位のある番号は、次の採番テーブルのほうが素直に書けます。

採番テーブル方式:2セッション同時実行で重複を再現する

採番テーブルは、「キーごとの最後の番号」を1行で持つテーブルです。

-- PostgreSQL・MySQL 共通
CREATE TABLE numbering (
  num_key  varchar(20) PRIMARY KEY,
  last_no  bigint      NOT NULL DEFAULT 0
);
INSERT INTO numbering (num_key) VALUES ('ORDER');

書き方は3通りあります。

番号 書き方 結果
[1] SELECT で読み、アプリで +1 して UPDATE する(ロックなし) 同時に実行すると同じ番号になる
[2] SELECT ... FOR UPDATE で行ロックを取ってから読む 重複しない
[3] UPDATE 1文で +1 し、更新後の値を受け取る 重複しない。文が少ない

確認用のスクリプト

2つのスレッドがそれぞれ別の接続(セッション)を開き、同時に採番します。番号を読んでから書くまでに0.2秒の間を入れて、処理が重なる状況を作っています。

"""採番テーブル方式を2セッションで同時に実行して、番号の重複を確かめる。

使い方: python3 numbering_test.py pg   (PostgreSQL。psycopg2 を使用)
        python3 numbering_test.py my   (MySQL。PyMySQL を使用)
"""
import sys
import threading
import time

DB = sys.argv[1]


def connect():
    if DB == "pg":
        import psycopg2
        return psycopg2.connect(dbname="appdb")
    import pymysql
    return pymysql.connect(unix_socket="/tmp/mysql.sock", user="root",
                           database="appdb")


def reset():
    con = connect()
    cur = con.cursor()
    cur.execute("UPDATE numbering SET last_no = 0 WHERE num_key = 'ORDER'")
    con.commit()
    con.close()


def naive(cur):
    """読んでから +1 して書く(ロックなし)"""
    cur.execute("SELECT last_no FROM numbering WHERE num_key = 'ORDER'")
    no = cur.fetchone()[0] + 1
    time.sleep(0.2)                       # ここで別の処理をしている想定
    cur.execute("UPDATE numbering SET last_no = %s WHERE num_key = 'ORDER'",
                (no,))
    return no


def for_update(cur):
    """行ロックを取ってから読む"""
    cur.execute("SELECT last_no FROM numbering "
                "WHERE num_key = 'ORDER' FOR UPDATE")
    no = cur.fetchone()[0] + 1
    time.sleep(0.2)
    cur.execute("UPDATE numbering SET last_no = %s WHERE num_key = 'ORDER'",
                (no,))
    return no


def one_statement(cur):
    """UPDATE 1文で +1 して、その値を受け取る"""
    if DB == "pg":
        cur.execute("UPDATE numbering SET last_no = last_no + 1 "
                    "WHERE num_key = 'ORDER' RETURNING last_no")
        no = cur.fetchone()[0]
    else:
        cur.execute("UPDATE numbering SET last_no = LAST_INSERT_ID(last_no + 1) "
                    "WHERE num_key = 'ORDER'")
        cur.execute("SELECT LAST_INSERT_ID()")
        no = cur.fetchone()[0]
    time.sleep(0.2)
    return no


def run(label, func):
    reset()
    t0 = time.time()
    log = []

    def session(name):
        con = connect()
        cur = con.cursor()
        no = func(cur)
        con.commit()
        log.append(f"  {name}: 番号 {no} を取得 ({time.time() - t0:.2f} 秒後)")
        con.close()

    threads = [threading.Thread(target=session, args=(n,))
               for n in ("セッションA", "セッションB")]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    con = connect()
    cur = con.cursor()
    cur.execute("SELECT last_no FROM numbering WHERE num_key = 'ORDER'")
    print(label)
    print("\n".join(sorted(log)))
    print(f"  採番テーブルの last_no = {cur.fetchone()[0]}")
    con.close()


run("[1] ロックなし(SELECT してから UPDATE)", naive)
run("[2] SELECT ... FOR UPDATE", for_update)
run("[3] UPDATE 1文で採番", one_statement)

(データベース名とソケットのパスは、appdb と /tmp/mysql.sock に置き換えて載せています。接続先はお使いの環境に合わせてください。)

実行結果(PostgreSQL 16.15、分離レベルは既定の read committed)

[1] ロックなし(SELECT してから UPDATE)
  セッションA: 番号 1 を取得 (0.21 秒後)
  セッションB: 番号 1 を取得 (0.21 秒後)
  採番テーブルの last_no = 1
[2] SELECT ... FOR UPDATE
  セッションA: 番号 2 を取得 (0.41 秒後)
  セッションB: 番号 1 を取得 (0.21 秒後)
  採番テーブルの last_no = 2
[3] UPDATE 1文で採番
  セッションA: 番号 1 を取得 (0.21 秒後)
  セッションB: 番号 2 を取得 (0.41 秒後)
  採番テーブルの last_no = 2

実行結果(MySQL 8.0.46、分離レベルは既定の REPEATABLE-READ)

[1] ロックなし(SELECT してから UPDATE)
  セッションA: 番号 1 を取得 (0.26 秒後)
  セッションB: 番号 1 を取得 (0.27 秒後)
  採番テーブルの last_no = 1
[2] SELECT ... FOR UPDATE
  セッションA: 番号 2 を取得 (0.54 秒後)
  セッションB: 番号 1 を取得 (0.34 秒後)
  採番テーブルの last_no = 2
[3] UPDATE 1文で採番
  セッションA: 番号 2 を取得 (0.50 秒後)
  セッションB: 番号 1 を取得 (0.29 秒後)
  採番テーブルの last_no = 2

結果の読み方

  • [1] は、2つのセッションが両方とも番号 1 を受け取りました。 どちらも last_no が 0 のときに読み、どちらも 1 を書いたためです。2件発番したのに last_no は 1 のままです。
  • [2] と [3] は、1 と 2 に分かれました。 後になったセッションは約0.2秒遅れて番号を受け取っています。先のセッションがコミットして行ロックを放すまで待っていたことが、時間の差からわかります。
  • どちらのセッションが先になるかは実行のたびに変わります。

[1] で起きたことを時間の順に並べると次のとおりです。

順 セッションA セッションB last_no
1 SELECT → 0 を読む 0
2 SELECT → 0 を読む 0
3 UPDATE last_no = 1、コミット 1
4 UPDATE last_no = 1、コミット 1
結果 番号 1 番号 1(重複) 1

行ロックを使う書き方(コピペ用)

PostgreSQL・MySQL共通で書ける、FOR UPDATE を使う形です。

BEGIN;  -- MySQL では START TRANSACTION; でも同じ
SELECT last_no FROM numbering WHERE num_key = 'ORDER' FOR UPDATE;
-- 読んだ値 + 1 を番号として使う
UPDATE numbering SET last_no = last_no + 1 WHERE num_key = 'ORDER';
-- 同じトランザクションの中で、番号を使う行(受注など)を登録する
COMMIT;

UPDATE 1文で済ませる形は、データベースごとに受け取り方が違います。

-- PostgreSQL:RETURNING で更新後の値を受け取る
UPDATE numbering SET last_no = last_no + 1
WHERE num_key = 'ORDER'
RETURNING last_no;
-- MySQL:LAST_INSERT_ID(式) に渡した値を、同じ接続から取り出す
UPDATE numbering SET last_no = LAST_INSERT_ID(last_no + 1)
WHERE num_key = 'ORDER';
SELECT LAST_INSERT_ID();

UPDATE は実行した時点で行ロックを取るので、読むことと書くことの間に他のセッションが入り込めません。 文が少なく、ロックの取り忘れが起きにくいので、まずこの形をおすすめします。

ロールバックすると番号も戻る(欠番にならない)

採番テーブルの更新は普通の行の更新なので、ロールバックすれば元に戻ります。PostgreSQLで、1件目を採番したあと、2件目をロールバックし、もう一度採番します。

UPDATE numbering SET last_no = 0 WHERE num_key = 'ORDER';
UPDATE numbering SET last_no = last_no + 1 WHERE num_key = 'ORDER' RETURNING last_no;
BEGIN;
UPDATE numbering SET last_no = last_no + 1 WHERE num_key = 'ORDER' RETURNING last_no;
ROLLBACK;
UPDATE numbering SET last_no = last_no + 1 WHERE num_key = 'ORDER' RETURNING last_no;
 last_no 
---------
       1
(1 row)

BEGIN
 last_no 
---------
       2
(1 row)

ROLLBACK
 last_no 
---------
       2
(1 row)

ロールバックした 2 が、次の採番でもう一度使われました。MySQLでも同じ流れを LAST_INSERT_ID で実行し、1、2(ロールバック)、2 の順になることを確かめています。

欠番を出さない条件は、「採番」と「その番号を使う行の登録」を同じトランザクションに入れることです。採番だけを先にコミットすると、あとの登録がエラーになったときにその番号が使われないまま残ります。

採番テーブル方式で気を付ける点

点 起きること 対応
同じキーの採番は1件ずつ 先のトランザクションがコミットするまで、後の採番は待つ 採番から登録・コミットまでを短くする。外部への通信や画面の入力待ちをはさまない
待ちの範囲 1つのキーに採番が集まると、そこが順番待ちになる キーを拠点や伝票の種類で分けると、待ちも分かれる
複数のキーを続けて採番する 取る順がセッションごとに違うと、互いに待ち合って止まることがある キーを取る順を決めておく(キーの昇順など)
登録後の取り消し コミット済みの番号は戻らない 行を消さずに「取消」の状態で残す。番号は再利用しない

年月・拠点ごとに1から振る採番テーブル

キーを「伝票の種類・拠点・年月」に分けると、月が変わったときや拠点が増えたときに、行が自動で増えて1から始まります。リセットの作業は要りません。

PostgreSQL(INSERT ... ON CONFLICT)

CREATE TABLE slip_numbering (
  prefix   char(1)  NOT NULL,           -- 伝票の種類(J:受注 など)
  site     char(2)  NOT NULL,           -- 拠点
  yyyymm   char(6)  NOT NULL,           -- 年月
  last_no  integer  NOT NULL,
  PRIMARY KEY (prefix, site, yyyymm)
);

-- 行が無ければ 1 で作り、あれば +1 する。1文なので同時実行でも重複しない
INSERT INTO slip_numbering VALUES ('J', '01', '202610', 1)
ON CONFLICT (prefix, site, yyyymm)
DO UPDATE SET last_no = slip_numbering.last_no + 1
RETURNING prefix || site || yyyymm || lpad(last_no::text, 5, '0') AS slip_no;

同じ文を2回、続けて拠点を 02 に変えて1回、年月を 202611 に変えて1回実行した結果です。

    slip_no     
----------------
 J0120261000001

    slip_no     
----------------
 J0120261000002

    slip_no     
----------------
 J0220261000001

    slip_no     
----------------
 J0120261100001
SELECT * FROM slip_numbering ORDER BY 1, 2, 3;
 prefix | site | yyyymm | last_no 
--------+------+--------+---------
 J      | 01   | 202610 |       2
 J      | 01   | 202611 |       1
 J      | 02   | 202610 |       1
(3 rows)

MySQL(INSERT ... ON DUPLICATE KEY UPDATE)

CREATE TABLE slip_numbering (
  prefix   char(1)  NOT NULL,
  site     char(2)  NOT NULL,
  yyyymm   char(6)  NOT NULL,
  last_no  int      NOT NULL,
  PRIMARY KEY (prefix, site, yyyymm)
);

INSERT INTO slip_numbering VALUES ('J', '01', '202610', LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE last_no = LAST_INSERT_ID(last_no + 1);
SELECT concat('J', '01', '202610', lpad(LAST_INSERT_ID(), 5, '0')) AS slip_no;

同じ2文を2回、年月を 202611 に変えて1回実行した結果です。

+----------------+
| slip_no        |
+----------------+
| J0120261000001 |
+----------------+
+----------------+
| slip_no        |
+----------------+
| J0120261000002 |
+----------------+
+----------------+
| slip_no        |
+----------------+
| J0120261100001 |
+----------------+

8セッション×100回で重複がないことを確かめる

上の文を、8つのセッションから同時に100回ずつ(合計800回)、新しい年月のキーに対して実行しました。行がまだ無い状態から始めています。

PostgreSQL: 取得 800 件 / 重複なしの件数 800 / 最小 1 / 最大 800
MySQL     : 取得 800 件 / 重複なしの件数 800 / 最小 1 / 最大 800

どちらも 1 から 800 までがちょうど1回ずつ発番されました。

UUIDv7:データベースに聞かずに発番する

UUIDは連番ではありませんが、「登録の前にIDが欲しい」「複数の場所で別々に発番したい」ときの選択肢です。RFC 9562 で定義された UUIDv7 は、先頭48ビットが Unix 時刻(ミリ秒)、続いてバージョン4ビット、乱数12ビット、バリアント2ビット、乱数62ビットという並びで、時刻の順に並ぶ値になります。Python 3.14 の標準ライブラリで生成した例です。

import time
import uuid

ids = []
for _ in range(3):
    ids.append(uuid.uuid7())
    time.sleep(0.002)
for u in ids:
    ms = int(u.hex[:12], 16)          # 先頭 48 ビットが Unix 時刻(ミリ秒)
    print(u, " version", u.version, " 時刻(ms)", ms)
print("生成順と文字列の並び順が同じ:", [str(u) for u in ids] == sorted(str(u) for u in ids))
01a10c28-687c-7545-8244-39545768a95c  version 7  時刻(ms) 1791205337212
01a10c28-687e-7046-8a46-37d982c92771  version 7  時刻(ms) 1791205337214
01a10c28-6881-7771-9147-eb8d42f7f357  version 7  時刻(ms) 1791205337217
生成順と文字列の並び順が同じ: True

36文字あり、人が読み上げたり手で入力したりする番号には向きません。内部のIDはUUIDv7、人が見る伝票番号は採番テーブル、と併用する形が取れます。

伝票番号体系のテンプレート

人が見る番号は、意味のある部分を組み合わせて作ります。

部分 桁 内容 例 決めること
接頭辞 1 伝票の種類 J(受注) 種類と文字の対応表。見分けにくい文字(O と 0、I と 1)を避ける
拠点 2 発番する拠点 01 拠点が増える見込みと桁
年月 6 発番した年月 202610 暦の年月か年度か。日まで入れるか
連番 5 年月・拠点ごとに1から 00123 1か月の最大件数。左を0で埋める
チェックディジット 1 入力の打ち違いを見つける数字 4 計算の方式。付けるかどうか
合計 15 J01202610001234 桁数は固定にする

記入用のひな形です(タブ区切り)。

部分  桁   内容  例   決めること
接頭辞 1   伝票の種類   J   種類と文字の対応表
拠点  2   発番する拠点  01  拠点が増える見込みと桁
年月  6   発番した年月  202610  暦の年月か年度か
連番  5   年月・拠点ごとに1から 00123   1か月の最大件数
チェックディジット   1   打ち違いを見つける数字 4   計算の方式

番号に入れるもの・入れないもの

入れる候補 判断 理由
伝票の種類、年月 入れてよい あとから変わらない。番号だけで見当が付く
拠点 発番した拠点としてなら入れてよい 「現在の担当拠点」は変わるので、その意味では入れない
顧客コード、担当者 入れない あとから変わることがある。番号が長くなる
状態(確定・取消など) 入れない 変わるたびに番号を変えることになる

チェックディジットと桁あふれを計算するPython

チェックディジットは、数字の部分から計算した1桁を末尾に付けて、入力の打ち違いを見つける仕組みです。ここでは、右端の桁から 3、1、3、1 と重みを掛けて合計し、10の倍数にするための数字を付ける方式(モジュラス10・ウェイト3と1)で書きます。

"""伝票番号の組み立て・チェックディジット・桁あふれ時期の計算"""
from datetime import date


def check_digit(digits: str) -> int:
    """モジュラス10・ウェイト3と1(右端の桁から 3, 1, 3, 1 ... を掛ける)"""
    total = sum(int(d) * (3 if i % 2 == 0 else 1)
                for i, d in enumerate(reversed(digits)))
    return (10 - total % 10) % 10


def build(prefix: str, site: str, day: date, seq: int, width: int = 5) -> str:
    if seq >= 10 ** width:
        raise OverflowError(f"連番 {seq} が {width} 桁に収まりません")
    digits = f"{site}{day:%Y%m}{seq:0{width}d}"
    return f"{prefix}{digits}{check_digit(digits)}"


def is_valid(slip_no: str) -> bool:
    digits, cd = slip_no[1:-1], slip_no[-1]
    return digits.isdigit() and cd.isdigit() and check_digit(digits) == int(cd)


no = build("J", "01", date(2026, 10, 5), 123)
print("伝票番号        :", no, " 桁数", len(no))
print("正しい番号      :", is_valid(no))
print("1桁の打ち違い   :", is_valid("J01202610001334"), "(J01202610001334)")
print("隣の桁の入れ違い:", is_valid("J01202610001324"), "(J01202610001324)")
try:
    build("J", "01", date(2026, 10, 5), 100000)
except OverflowError as e:
    print("桁あふれ        :", e)

print()
print("月ごとにリセットする連番が上限に届く時期(毎年 20% ずつ件数が増える想定)")
for width in (4, 5, 6):
    limit = 10 ** width - 1
    monthly, year = 3000, 2026
    while monthly <= limit and year < 2100:
        year += 1
        monthly = int(monthly * 1.2)
    print(f"  連番 {width} 桁(上限 {limit:>7,}): 月 3,000 件から始めて {year} 年に月 {monthly:,} 件で上限を超える")

print()
print("リセットしない連番が上限に届くまでの年数")
for name, limit in (("integer(4バイト)", 2**31 - 1), ("bigint(8バイト)", 2**63 - 1)):
    for per_day in (10_000, 1_000_000):
        years = limit / per_day / 365
        print(f"  {name}: 1日 {per_day:>9,} 件で 約 {years:,.0f} 年")

print()
a, b = build("J", "01", date(2026, 10, 5), 16), build("J", "01", date(2026, 10, 5), 61)
print("差が5の隣り合う桁(16 と 61):", a, b, " チェックディジットが同じ:", a[-1] == b[-1])
伝票番号        : J01202610001234  桁数 15
正しい番号      : True
1桁の打ち違い   : False (J01202610001334)
隣の桁の入れ違い: False (J01202610001324)
桁あふれ        : 連番 100000 が 5 桁に収まりません

月ごとにリセットする連番が上限に届く時期(毎年 20% ずつ件数が増える想定)
  連番 4 桁(上限   9,999): 月 3,000 件から始めて 2033 年に月 10,747 件で上限を超える
  連番 5 桁(上限  99,999): 月 3,000 件から始めて 2046 年に月 114,973 件で上限を超える
  連番 6 桁(上限 999,999): 月 3,000 件から始めて 2058 年に月 1,025,095 件で上限を超える

リセットしない連番が上限に届くまでの年数
  integer(4バイト): 1日    10,000 件で 約 588 年
  integer(4バイト): 1日 1,000,000 件で 約 6 年
  bigint(8バイト): 1日    10,000 件で 約 2,526,951,242,974 年
  bigint(8バイト): 1日 1,000,000 件で 約 25,269,512,430 年

差が5の隣り合う桁(16 と 61): J01202610000169 J01202610000619  チェックディジットが同じ: True

読み取れることは次のとおりです。

  • 1桁の打ち違い(…1234 を …1334)と、隣の桁の入れ違い(…1234 を …1324)は、どちらも正しくない番号として見つかりました。
  • この方式では、隣り合う2桁の差が5のとき(1と6、0と5など)の入れ違いは見つかりません。 最後の行がその例です。チェックディジットは打ち違いを減らす仕組みとして使い、番号が実在するかは検索で確かめます。
  • 連番の桁は「いまの件数」ではなく「増えたあとの件数」で決めます。 月3,000件で始めて毎年20%増える想定では、4桁は2033年、5桁でも2046年に上限を超えます。
  • 連番が上限を超えたときは、黙って桁を増やさずにエラーにします。桁数が変わると、番号を並べ替えたときの順や、桁を前提にした入力チェックに影響するためです。
  • 主キーにする内部の連番は、bigint にしておけば上限を気にせずに済みます。

採番設計のチェックリスト

確認すること 見るところ
主キーと、人が見る番号を別の列にしている テーブル定義
欠番を許すかどうかが決まっている 番号体系の定義
採番と本体の登録が同じトランザクションに入っている 処理の設計
採番を UPDATE 1文、または FOR UPDATE で行ロックを取って行っている SQL
番号の列に一意制約がある(重複を最後に止める) テーブル定義
採番からコミットまでに、外部への通信や入力待ちがない 処理の設計
年月・拠点などのリセットの単位が、採番テーブルのキーになっている 採番テーブル
連番の桁が、数年先の件数で足りる 桁あふれの計算
取り消した番号を再利用しない 運用の決めごと

よくある質問

Q. MAX(番号)+1 で採番してもよいですか? A. 同時に2件登録すると、どちらも同じ最大値を読んで同じ番号になります。記事の [1] と同じ流れです。番号の列に一意制約があれば、後から登録したほうが duplicate key value violates unique constraint のエラーになって重複は防げますが(PostgreSQLで確認)、やり直しが必要になります。採番テーブルを UPDATE 1文で更新する形にすると、順番に1件ずつ発番されます。

Q. 欠番は必ずなくす必要がありますか? A. 内部の主キーなら、欠番があっても動作に影響しません。人が見る伝票番号で「番号が連続していること」を求められる場合に、採番テーブル方式を選びます。欠番をなくすと同じキーの採番が1件ずつになるので、求められていない番号まで連続にする必要はありません。

Q. 採番テーブルの待ちが気になるときはどうしますか? A. まず、採番からコミットまでの処理を短くします。次に、キーを拠点や伝票の種類で分けて、待ちが1か所に集まらないようにします。それでも足りず、欠番を許せるなら、連番の部分をシーケンスに替える方法があります。

Q. 採番テーブルとIDENTITYを同じテーブルで両方使ってよいですか? A. 使えます。主キーは IDENTITY や AUTO_INCREMENT の bigint、伝票番号は採番テーブルで作った文字列にして一意制約を付ける、という組み合わせがよく使われます。外部キーは短い主キーで結び、画面と帳票には伝票番号を出します。

まとめ

  • 採番方式は、欠番を許せるか・リセットの単位があるか・どこで発番するかで選びます。 内部の主キーは IDENTITY/AUTO_INCREMENT、人が見る連番は採番テーブル、データベースの外で発番するなら UUIDv7 が目安です。
  • シーケンス、IDENTITY、AUTO_INCREMENT は、ロールバックで欠番になります。 PostgreSQL・MySQLとも、1 の次が 3 になることを確かめました。
  • 採番テーブルを「読んでから書く」と、2セッションで同じ番号になります。 FOR UPDATE で行ロックを取るか、UPDATE 1文(PostgreSQLは RETURNING、MySQLは LAST_INSERT_ID)で採番すると解消します。
  • 採番と登録を同じトランザクションに入れると、ロールバックで番号も戻り、欠番になりません。 その代わり、同じキーの採番は1件ずつ進みます。
  • 年月・拠点ごとの連番は、採番テーブルのキーに入れると、INSERT ... ON CONFLICT や ON DUPLICATE KEY UPDATE の1文で、行の作成と採番を同時に行えます。
  • 伝票番号は、接頭辞・拠点・年月・連番・チェックディジットを固定の桁で組み合わせ、連番の桁は数年先の件数で決めます。

参考・出典

  • PostgreSQL 16 Documentation「Sequence Manipulation Functions」 https://www.postgresql.org/docs/16/functions-sequence.html
  • MySQL 8.0 Reference Manual「InnoDB AUTO_INCREMENT Handling」 https://dev.mysql.com/doc/refman/8.0/en/innodb-auto-increment-handling.html
  • RFC 9562「Universally Unique IDentifiers (UUIDs)」 https://www.rfc-editor.org/rfc/rfc9562.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「採番設計の方法|シーケンス・採番テーブル・UUIDの比較表とSQL例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?