重複や番号の欠落を防ぐ同時実行安全な請求書番号付け
複数のユーザーが同時に請求書やチケットを作成しても、番号の重複や予期しない欠番を防ぐための実践的なパターンを学びます。

二人が同時にレコードを作成すると何が問題になるか
午後4時55分の忙しいオフィスを想像してください。二人が請求書を完成させ、ほぼ同時に保存ボタンを押します。両方の画面に一時的に「請求書 #1042」と表示される。片方のレコードだけが保存されるか、最悪の場合は両方が同じ番号で保存されてしまう。これが実務で最もよく見られる症状です:負荷時にのみ現れる重複番号。
チケットでも同じことが起きます。二人のエージェントが同時に同じ顧客のチケットを作成し、あなたのシステムが「次の番号」を最近のレコードを見て決めていると、両方が同じ「最新値」を読み取ってしまい、同じ次番号を選ぶことがあります。
二つ目の症状はより微妙です:番号の欠落です。#1042 の次が #1044 で #1043 がない、ということが起きます。これは多くの場合エラーやリトライの後に起きます。あるリクエストが番号を確保した後、バリデーションエラーやタイムアウト、ユーザーがタブを閉じたことで保存が失敗する。あるいはバックグラウンドジョブがネットワークの一時的な不具合でリトライし、最初の試行が既に番号を消費しているにも関わらず新しい番号を掴んでしまう。
請求書では、番号付けは監査記録の一部なので重要です。会計担当者は各請求書が一意に識別されることを期待しますし、顧客は支払いやサポートのメールで請求書番号を参照することがあります。チケットでは、その番号が会話やレポート、エクスポートで使われるハンドルです。重複は混乱を招きます。欠番は何も不正がなかったとしてもレビュー時に疑問を生むことがあります。
ここで早めに伝えておきたい主要な期待は:すべての番号付け方法が同時実行安全性と欠番の両方を満たせるわけではない、ということです。多人数でも重複が起きない同時実行安全な請求書番号付け(重複なし)は達成可能であり、譲れない要件にするべきです。欠番がないことも可能ですが、追加ルールを設ける必要があり、ドラフト、失敗、取り消しの扱いを変えることが多いです。
問題を整理する良い方法は、番号に何を保証してほしいかを問うことです:
- 絶対に繰り返さないこと(常に一意)
- なるべく増加順であること(あれば良い)
- 欠番がないこと(これを求めるなら設計上明確にする)
ルールを決めれば、技術的な解決策の選択がずっと簡単になります。
なぜ重複と欠番が起きるのか
多くのアプリは単純なパターンに従います:ユーザーが保存をクリックすると、アプリが「次の請求書番号」を取りに行き、その番号で新しいレコードを挿入します。単独で操作しているときはこれで問題ありません。
問題は二つの保存がほとんど同時に起きたときに始まります。両方のリクエストが「次の番号を取得する」段階に到達し、どちらも挿入を完了する前に同じ「次」の値を読むと、両方が同じ番号を書き込もうとします。これは競合状態(レースコンディション)で、結果はロジックではなくタイミングに依存します。
典型的なタイムラインは次の通りです:
- リクエスト A が次の番号を読み取る:1042
- リクエスト B が次の番号を読み取る:1042
- リクエスト A が請求書 1042 を挿入する
- リクエスト B が請求書 1042 を挿入する(あるいは一意性ルールで失敗する)
データベースが二回目の挿入を止めないと重複が発生します。アプリ側で「この番号が既に使われているか?」とだけチェックしていると、チェックと挿入の間の競合に負けることがあります。
欠番は別の問題です。番号を「確保」したが、そのレコードが実際のコミット済み請求書やチケットにならない場合に発生します。一般的な原因は支払い失敗、後で見つかったバリデーションエラー、タイムアウト、番号が割り当てられた後にユーザーがタブを閉じることなどです。挿入が失敗して何も保存されなくても、番号は既に消費されていることがあります。
隠れた同時実行はこれを悪化させます。問題は単に「二人の人が保存をクリックする」だけではありません。次のようなこともあります:
- 並列にレコードを作る API クライアント
- バッチで走るインポート処理
- 夜間に請求書を生成するバックグラウンドジョブ
- 回線が不安定なモバイルアプリからのリトライ
つまり根本原因は:(1) 複数リクエストが同じカウンタ値を読むタイミングの衝突、(2) トランザクション成功が確定する前に番号を割り当ててしまうこと、です。どの方針を許容するか(重複なし、欠番なし、あるいはその両方)を決める必要があります。
解決策を選ぶ前に番号付けルールを決める
同時実行安全な請求書番号付けを設計する前に、番号がビジネス上で何を意味するのかを書き出してください。よくある間違いは、技術的方法を先に選んでしまい、会計や法務の要件と食い違うことです。
まず混同されがちな二つの目標を分けて考えます:
- 一意性(Unique):二つの請求書やチケットが同じ番号を持たないこと。
- 欠番なし(Gapless):番号が一意で、かつ厳密に連続していること(欠番がない)。
多くの実システムは「一意性のみ」を目指し、欠番を許容します。欠番は正常な理由で起き得ます:ユーザーがドラフトを開いて放棄する、支払いが失敗して番号が確保される、あるいは作成後にそのレコードを無効化する等。ヘルプデスクのチケットでは欠番はほとんど問題になりません。請求書でも、欠番を監査記録(無効、キャンセル、テスト等)で説明できれば受け入れられることが多いです。欠番無しを望むなら、追加ルールと運用上の摩擦が必要になります。
次に、カウンタの適用範囲(スコープ)を決めてください。言い回しの小さな違いが設計を大きく変えます:
- 全体で一つのシーケンスか、会社/テナントごとの別シーケンスか?
- 毎年リセットするか(2026-000123 のように)それともリセットしないか?
- 請求書、貸方票、チケットで別のシリーズが必要か?
- 人間に優しい書式(プレフィックス、区切り)か、内部用の番号だけでよいか?
具体例:複数のクライアント会社を持つ SaaS なら、請求書番号は会社ごとに一意で年度ごとにリセットする必要があり、チケットはグローバルに一意でリセットしない、という要件があり得ます。これは二つの異なるカウンタ設計が必要です。
本当に欠番なしを必要とするなら、番号が割り当てられた後にどのイベントを許容するか明確にしてください。例:請求書を削除できるか、キャンセルのみか。ユーザーがドラフトを保存できるが番号は最終承認時に割り当てるか。これらの選択はデータベース技術よりも重要な場合が多いです。
構築前に短い仕様を書き出してください:
- どのレコードタイプがシーケンスを使うか?
- いつ番号を「使用済み」とみなすか(ドラフト、送信済み、入金済みなど)?
- スコープは何か(グローバル、会社ごと、年度ごと、シリーズごと)?
- 無効化や訂正はどう扱うか?
AppMaster では、この種のルールはデータモデルと業務プロセスフローの横に置くべきです。そうすればチーム全員が API、Web UI、モバイルで同じ振る舞いを実装できます。
よくあるアプローチとそれぞれの保証
「請求書番号付け」の話になると、多くの人が二つの異なる目的((1) 同じ番号を二度生成しない、(2) 欠番を出さない)を混同します。ほとんどのシステムは最初の目的を簡単に保証できます。二つ目はずっと難しく、トランザクション失敗やドラフト放棄、レコード無効化のたびに欠番が生じ得ます。
アプローチ 1:データベースのシーケンス(高速で一意)
PostgreSQL の sequence は、負荷下でも一意で増加する番号を手早く提供する最もシンプルな方法です。データベースは同時実行に強く、多数のユーザーが同時にレコードを作る場合でもシーケンス値を速やかに渡してくれます。
得られるもの:一意性と(ほぼ)増加順。得られないもの:欠番の不発生。挿入が失敗した後に番号が割り当てられていれば、その番号は“消費”され、欠番になります。
アプローチ 2:一意制約+リトライ(データベースに決定を任せる)
アプリ側で候補番号を生成して保存し、一意制約で衝突を弾く方法です。衝突が起きたら新しい番号でリトライします。
低い同時実行なら機能しますが、高負荷ではリトライが多発してトランザクション失敗やデバッグが難しいスパイクを生むことがあります。欠番なしを保証するには厳格な予約ルールと組み合わせる必要があり、複雑さが増します。
アプローチ 3:ロック付きのカウンタ行(ギャップレスを目指す)
欠番を本気で避けたいなら、専用のカウンタテーブル(各スコープごとに一行)を使い、その行をトランザクション内でロックしてインクリメントします。
これが通常のデータベース設計でギャップレスに最も近い方法ですが、コストがあります:すべての書き込みがそのホットスポットを待つためスループットが落ちますし、長いトランザクションやタイムアウト、デッドロックのリスクが高まります。
アプローチ 4:別サービスによる予約(特殊ケース向け)
番号発行を別サービスに集中させることで、複数のアプリやデータベースをまたがるルールを統一できます。複数のシステムが番号を発行しており統合できない状況で有効です。
ただし運用上のリスクが増えます:別のサービスを正しく、高可用に、かつ一貫して動かさねばなりません。
実務的に考えると次のように整理できます:
- シーケンス:一意、速い、欠番は許容する
- 一意+リトライ:一意、低負荷では簡単、高負荷ではスラッシングし得る
- ロック付きカウンタ行:ギャップレスにできるが高負荷時は遅くなる
- 別サービス:複数システムで柔軟だが最も複雑で故障モードが増える
AppMaster のようなノーコードツールで構築する場合も選択肢は同じです:最終的な正しさはデータベースの制約とトランザクションに委ねるべきで、アプリロジックはリトライや分かりやすいエラーメッセージで補助する役割です。
ステップバイステップ:シーケンスと一意制約で重複を防ぐ
重複防止が最優先(欠番は許容する)なら、最も簡潔で信頼できるパターンは:内部キーはデータベース生成にし、顧客向けの表示番号は一意制約で保護することです。
二つの概念を分離します。内部結合・編集・エクスポート用にデータベース生成の ID(identity/sequence)を使い、invoice_no や ticket_no は表示用の別カラムとして扱います。
PostgreSQL における実用的な設定例
次に示す一般的な PostgreSQL の方法は、「次の番号」ロジックをデータベース内に置き、同時実行を正しく扱います。
-- Internal, never-shown primary key
create table invoices (
id bigint generated always as identity primary key,
invoice_no text not null,
created_at timestamptz not null default now()
);
-- Business-facing uniqueness guarantee
create unique index invoices_invoice_no_uniq on invoices (invoice_no);
-- Sequence for the visible number
create sequence invoice_no_seq;
表示用番号は挿入時に生成します(select max(invoice_no) + 1 のようなやり方は避ける)。一つのシンプルなパターンは INSERT の中でシーケンス値をフォーマットする方法です:
insert into invoices (invoice_no)
values (
'INV-' || lpad(nextval('invoice_no_seq')::text, 8, '0')
)
returning id, invoice_no;
50 人が同時に「請求書を作成」しても、各挿入は異なるシーケンス値を得るので一意インデックスが重複を防ぎます。
衝突が起きたときの対応
プレーンなシーケンスでは衝突は稀です。衝突が起きやすくなるのは「年度ごとにリセットする」「テナントごとに分ける」「ユーザーが編集できる番号を許す」といった追加ルールを加えた場合です。だからこそ一意制約は重要です。
アプリ側では、一意制約違反を小さなリトライループで処理します。単純かつ上限を設けたリトライ方針を使ってください:
- INSERT を試みる
- invoice_no に対する一意違反が返ってきたら再試行する
- 小さな回数だけ再試行し、失敗したら分かりやすいエラーを表示する
これは通常うまく働きます。リトライが発生するのは異常時のみで、二つのコードパスが同じフォーマット番号を生成したような珍しいケースだけです。
レースウィンドウを小さく保つ
番号を UI 側で計算したり、先に読み取ってから挿入するような予約方式は避けてください。番号生成はデータベース書き込みに極力近い場所で行います。
AppMaster と PostgreSQL を組み合わせるなら、Data Designer で id を identity primary key に設定し、invoice_no に一意制約を付け、create フロー内で invoice_no を生成して挿入と一緒に行うようにします。こうすることでデータベースが真実のソースとなり、同時実行問題は PostgreSQL の得意分野で処理されます。
ステップバイステップ:行ロックでギャップレスなカウンタを作る
本当に欠番を避けたい場合、トランザクショナルなカウンタテーブルと行ロックを使えます。考え方は単純です:特定スコープの次番号を取るのは同時に一つのトランザクションだけにし、順番どおり番号を渡すようにします。
まずスコープを決めます。多くのチームは会社ごと、年度ごと、シリーズごとに別々のシーケンスが必要です。カウンタテーブルは各スコープの最後に使われた番号を保存します。
PostgreSQL の行ロックを使った実用パターンは次の通りです:
number_countersのようなテーブルを作り、company_id,year,series,last_numberを持たせ、(company_id, year, series)にユニークキーを張る。\n2. データベーストランザクションを開始する。\n3.SELECT last_number FROM number_counters WHERE ... FOR UPDATEで該当行をロックする。\n4.next_number = last_number + 1を計算し、カウンタ行をlast_number = next_numberに更新する。\n5.next_numberを使って請求書やチケット行を挿入し、コミットする。
重要なのは FOR UPDATE です。負荷時でも重複は発生しません。「二人が同じ番号を得る」こともない。二番目のトランザクションは最初がコミットまたはロールバックするまで待機します。その待機がギャップレスを得る代償です。
新しいスコープの初期化
新しい会社や年度、シリーズが現れたときの方針も必要です。一般的な選択肢:
- 事前にカウンタ行を作っておく(たとえば翌年分を12月に作る)
- 必要時に作成する:
last_number = 0で挿入を試み、既に存在すれば通常のロック・インクリメントフローにフォールバックする
ノーコードツールで作る場合も「ロック、インクリメント、挿入」操作を一つのトランザクション内にまとめて、すべてが成功するかすべてがなかったことになるようにします。
例外ケース:ドラフト、保存失敗、キャンセル、編集
多くの番号付けバグは混沌とした部分に現れます:投稿されないドラフト、保存が失敗したもの、請求書の無効化、誰かが番号を見た後の編集など。同期安全な番号付けを目指すなら、番号がいつ「実際のもの」になるかを明確に定める必要があります。
最大の決断はタイミングです。誰かが「新規請求書」をクリックした瞬間に番号を割り当てると、放棄されたドラフトから欠番が生じます。請求書を確定(posted/issued など)したときにのみ番号を割り当てれば、番号はよりタイトで説明しやすくなります。
失敗やロールバックが期待とデータベース挙動で食い違うことがよくあります。標準的なシーケンスでは、一度番号が取られるとトランザクションがその後失敗しても番号は消費されます。これは正常かつ安全な動作ですが欠番を生みます。本当に欠番を許さない方針なら、番号は最終ステップでのみ割り当て、トランザクションがコミットしたときにはじめて確定する必要があります。通常は単一のカウンタ行をロックし、最終番号を書き込んでコミットする設計になります。
キャンセルや無効化で番号を再利用してはいけません。番号と履歴はそのまま残し、ステータスを変更して理由を記録します。監査人や顧客は訂正があったときも履歴が保たれることを期待します。
編集はより単純です:番号が外部に見えてしまったら、それは恒久的なものとして扱います。共有、エクスポート、印刷された後で請求書やチケットの番号を書き換えてはいけません。訂正が必要なら新しいドキュメントを発行して元のものを参照する(例:クレジットノートや置換ドキュメント)べきです。
多くのチームが採る実用的なルール:
- ドラフトは最終番号を持たない(内部 ID や「DRAFT」を使う)
- 番号は「Post/Issue」などの確定時に同一トランザクション内で割り当てる
- 無効化やキャンセルは番号を保持し、明確なステータスと理由を付ける
- 印刷/メール送信済みの番号は変更しない
- インポートは元の番号を保持し、カウンタを最大値の次に更新する
マイグレーションやインポートは慎重に扱ってください。既存の請求書番号をそのまま移行してから、カウンタをインポート済みの最大値以降で開始するのが一般的です。フォーマットが混在する場合は、表示番号をそのまま保存し、内部キーを別に持つのが安全です。
例:ヘルプデスクは高速でチケットを作るが多くはドラフトです。エージェントが「送信」を押したときにのみチケット番号を割り当てれば、放棄ドラフトで番号を無駄にしません。AppMaster のようなノーコードツールでも同じ考え方が適用されます:ドラフトは公開番号なしで保存し、「submit」ビジネスプロセスの中で最終番号を生成して確定させます。
重複や予期せぬ欠番を引き起こすよくあるミス
ほとんどの番号付け問題は一つの単純な考え方から来ます:番号を表示用の値としか扱っていないことです。複数の人が同時に保存する場合、次の番号を決める明確な単一の場所と、失敗時の一意なルールが必要です。
古典的な間違いはアプリケーションコード内で SELECT MAX(number) + 1 を使うことです。単体では問題なく見えますが、二つのリクエストがどちらも MAX を読み取ると、同じ次値を生成してしまいます。チェックしてリトライするようにしても、ピークトラフィック時に余計な負荷やスパイクを作ることがあります。
別の典型例はクライアント側(ブラウザやモバイル)で番号を生成してから保存することです。クライアントは他のユーザーの操作を知らないため、保存が失敗したときに安全に番号を予約できません。クライアント生成の番号は「Draft 12」のような一時ラベルには使えますが、正式な請求書やチケット ID には向きません。
PostgreSQL のシーケンスが欠番なしであると期待してしまうのも誤りです。シーケンスは一意性のために設計されており、トランザクションのロールバックや ID のプリフェッチ、データベース再起動で番号が飛ぶことがあります。要件が「重複なし」であればシーケンスと一意制約の組み合わせが通常は最適です。本当に「欠番なし」が必要なら行ロック等の別パターンとスループットのトレードオフを受け入れる必要があります。
ロック範囲が広すぎると逆効果になることもあります。全ての番号付けに対して単一のグローバルロックを使うと、すべての作成処理が直列化され、会社や地点、ドキュメントタイプごとに分けられるはずの作業まで遅くなり、ユーザーが保存で「たまに」詰まるように感じる原因になります。
実装時にチェックすべきミス:
MAX + 1を使っていて、データベースレベルの一意制約がない- 最終番号をクライアント側で生成し、後で競合を「直そうとする」
- PostgreSQL シーケンスを欠番なしだと期待し、欠番をエラー扱いしてしまう
- すべてを一つの共有カウンタでロックし、分割可能なカウンタを使わない
- 1 ユーザーでしかテストしておらず、レース条件が本番で出る
実用的なテスト案:並列で 100〜1,000 レコードを作成し、重複や予期しない欠番がないか確認する。AppMaster のようなツールでも、最終番号割り当てはサーバーサイドの単一トランザクション内で行い、UI フローに分散させないようにします。
出荷前の簡単チェックリスト
請求書やチケットの番号付けをリリースする前に、負荷の高い状況で失敗しやすい部分を簡単に点検してください。目的はシンプルです:各レコードがビジネス番号をちょうど一つ持ち、50 人が同時に「作成」を押してもルールが守られること。
出荷前の実用チェックリスト:
- ビジネス番号フィールドにデータベースレベルの一意制約があることを確認する(UI のチェックだけでは不十分)。これは最後の防衛線です。
- 番号がレコードを保存するのと同じデータベーストランザクション内で割り当てられていることを確認する。番号割り当てと保存が複数のリクエストに分かれていると、いずれ重複が出ます。
- 欠番を許容しないなら、レコードが確定したとき(ドラフトではなく、発行時など)にのみ番号を割り当てるようにする。ドラフトや放棄、支払い失敗が欠番の主な原因です。
- 稀な競合のためにリトライ戦略を用意する。行ロックやシーケンスを使っていても、直列化エラーやデッドロック、一意違反が発生することはあり得ます。短いバックオフ付きの単純なリトライで十分なことが多いです。
- UI、公開 API、バルクインポートなど、すべての入力経路で 20〜100 並列で作成する負荷テストを行う。バースト、遅いネットワーク、二重送信の混在など現実的なパターンで試す。
セットアップを検証する簡単な方法は、忙しいヘルプデスクの瞬間をシミュレートすることです。二人のエージェントが「新しいチケット」フォームを開き、一方が Web アプリから送信し、同時にインポートジョブがメール受信箱からチケットを挿入する。実行後に全番号が一意で適切な形式か、失敗が半端な保存を残していないか確認します。
AppMaster でワークフローを作る場合も同じ原則です:番号割り当てはデータベーストランザクション内に収め、PostgreSQL の制約に頼り、UI と API 両方で同じ振る舞いをテストしてください。本番のユーザーが増えた初日に驚かないためにここで検証します。
例:混雑するヘルプデスクのチケットと次にやるべきこと
サポートデスクではエージェントが Web アプリでチケットを作成しつつ、統合がチャットやメールからチケットを同時に作ることがあります。皆は T-2026-000123 のようなチケット番号を期待し、各番号が一つのチケットに対応することを望みます。
素朴な方法は「最後のチケット番号を読み取り +1 して保存する」ですが、負荷下では二つのリクエストが同じ最後の番号を読み取ってしまい、両者が同じ次番号を計算して重複が起きます。失敗後にリトライで直そうとすると、意味のない欠番が生じがちです。
データベースに一意制約を入れれば、アプリ側が素朴でも重複は止められます。ticket_number カラムに UNIQUE を追加し、二つのリクエストが同じ番号を試みたときには一方が失敗しリトライできます。これが請求書番号付け全般における核心です:一意性は UI でなくデータベースに強制させる。
ギャップレスを求めるならワークフローを変える必要があります。最終番号をチケット作成時(ドラフト時)に割り当てるのではなく、確定時に割り当てるようにします。こうすれば放棄ドラフトが番号を消費しません。
単純なテーブル設計例:
- tickets: id, created_at, status (Draft, Open, Closed), ticket_number (nullable), finalized_at
- ticket_counters: key (例 "tickets_2026"), next_number
AppMaster では Data Designer でこれをモデル化し、Business Process Editor で次のように組みます:
- Create Ticket: status=Draft、ticket_number なしでチケットを挿入
- Finalize Ticket: トランザクション開始、カウンタ行をロック、ticket_number を設定し next_number をインクリメントしてコミット
- テスト: 同時に二つの "Finalize" アクションを走らせても重複が発生しないことを確認する
次にやること:ルール(一意のみか本当に欠番なしなのか)を決める。欠番を許容できるなら、データベースシーケンス+一意制約で十分で流れもシンプルです。欠番なしが必要なら、番号割り当てを確定ステップに移し、ドラフトを正しく扱い、複数エージェントや API のバーストで負荷テストしてから本番へ進んでください。


