
この記事は、Bill One開発Unit ブログリレー2025の第3弾になります!
はじめに
こんにちは。技術本部 Bill One Engineering Unit の佐々木です。2021年7月に入社以来、主に請求書の発行機能、および入金消込機能の開発を担当しています。
Bill Oneの請求書発行では、1つのファイルを複数の発行前請求書に一括で添付できる機能を開発しリリースしました。この機能により、例えば年末年始のお知らせや料金改定のお知らせなど、同じファイルを多数の請求書に添付する作業を自動化できるようになりました。
本記事では、この機能開発で採用したPostgreSQLのロック機能と外部キー制約を活用した並行制御の仕組みについて解説します。
実装した機能の概要
- 検索条件で絞り込んだ発行前請求書(一括発行と同等の件数)に対して、1つのファイルを一括で添付できる
- 非同期処理により、ユーザーを待たせずに処理を実行
- 添付できない請求書(既に発行済みなど)は自動的に除外し、理由を記録・表示する
テーブル構成
今回の実装に関わる主要なテーブル構成は次の通りです。
erDiagram
invoice ||--o{ attachment_file : "ON DELETE CASCADE"
invoice {
uuid invoice_uuid PK
string tenant_name_id
}
attachment_file {
uuid attachment_file_uuid PK
uuid invoice_uuid FK
string file_name
}
発行処理では、発行前請求書(invoice)を削除し、発行済請求書として別テーブルに移動します。一括添付処理では、attachment_fileテーブルへファイル情報をINSERTし、外部キーにより対象の発行前請求書にリンクします。外部キー制約により、請求書発行時にinvoiceが削除されると、attachment_fileも自動削除されます(ON DELETE CASCADE)。
並行制御の実装:FOR UPDATEロックと外部キー制約
なぜFOR UPDATEロックが必要なのか
一括添付機能では、添付できなかった請求書について理由を記録・表示します。例えば「既に発行済み」などです。
仮にFOR UPDATEロックがない場合、発行処理のSELECT後からDELETE実行までの間に一括添付処理のINSERTが成功してしまう可能性があります。その直後に発行処理のDELETEが実行されると、ON DELETE CASCADEで添付ファイルも削除されます。この場合、ユーザーには「添付成功」と表示されますが、実際には発行時に添付されておらず、除外理由も記録できません。
FOR UPDATEロックを採用することで、発行処理が親レコード(invoice)をロックし、一括添付処理のINSERTを待機させます。発行完了後、INSERTは外部キー制約違反で明確に失敗し、リトライ時に「既に発行済み」として除外リストに記録されます。これにより、ユーザーに正確な情報を提供できます。
SELECT invoice_uuid FROM invoice WHERE invoice_uuid IN (...) ORDER BY invoice_uuid FOR UPDATE
並行制御の仕組み
PostgreSQLでは、外部キー制約による参照整合性を保証するため、子テーブルへのINSERTやUPDATE時に親テーブルの関連レコードへFOR KEY SHAREロックが自動取得されます。
FOR KEY SHAREロックは、参照先レコードのキー値の変更やDELETEを防ぎつつ、キー値以外の列の更新は許可する、外部キー制約のために最適化されたロックモードです。FOR UPDATEロックと競合するため、この仕組みを活用して並行制御を実現します。
一括添付処理がattachment_fileテーブルへINSERTを実行すると、外部キー制約により親テーブル(invoice)の関連レコードへのFOR KEY SHAREロックが自動的に取得されます。次の動作になります。
発行処理が先の場合
sequenceDiagram
participant T1 as 発行処理
participant Invoice as invoiceテーブル
participant Attachment as attachment_fileテーブル
participant T2 as 一括添付処理
T1->>Invoice: SELECT ... FOR UPDATE
Note over Invoice: FOR UPDATEロック取得
T2->>Attachment: INSERT
Attachment->>Invoice: FOR KEY SHAREロック取得試行
Note over T2: 待機 (FOR UPDATEと競合)
T1->>Invoice: DELETE
T1->>Invoice: COMMIT
Note over T2: 待機解除→外部キー違反エラー
Note over T2: 再実行時に除外リストに記録
FOR UPDATEロックを保持しているため、一括添付処理のFOR KEY SHAREロック取得が待機します。発行完了後、親レコードが存在しないため外部キー違反で失敗し、一括添付処理の再実行時は除外リストへ記録されます。
一括添付処理が先の場合
sequenceDiagram
participant T1 as 発行処理
participant Invoice as invoiceテーブル
participant Attachment as attachment_fileテーブル
participant T2 as 一括添付処理
T2->>Attachment: INSERT
Attachment->>Invoice: FOR KEY SHAREロック取得
Note over Invoice: FOR KEY SHAREロック取得
T1->>Invoice: SELECT ... FOR UPDATE (ロック取得試行)
Note over T1: 待機 (FOR KEY SHAREと競合)
T2->>Attachment: COMMIT (ロック解放)
T1->>Invoice: SELECT ... FOR UPDATE
Note over T1: 添付ファイルを確認
T1->>Invoice: DELETE (ON DELETE CASCADE)
T1->>Invoice: COMMIT
FOR KEY SHAREロックを保持しているため、発行処理のFOR UPDATEロック取得が待機します。一括添付処理が完了後、発行処理が実行されます。SELECT時点で添付ファイルを確認し、ビジネスロジックに従った処理を実行します。添付ファイルはON DELETE CASCADEで削除されます。
まとめ
本記事では、Bill Oneの一括ファイル添付機能の実装過程で直面した並行制御の課題について解説しました。
実装のポイント
FOR UPDATEロックと外部キー制約の組み合わせ - 発行処理:FOR UPDATEロックで親レコードをロック - 一括添付処理:INSERT時に外部キー制約により自動的にFOR KEY SHAREロックを取得 - 両ロックが競合することで、先に実行された処理が完了するまで後続処理が待機
今回の実装を通じて、PostgreSQLの外部キー制約が自動的にFOR KEY SHAREロックを取得する仕組みを活用することで、明示的なロック管理なしに並行制御を実現できることを学びました。
Sansan技術本部ではカジュアル面談を実施しています

技術本部では中途・新卒採用向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。