
2026/08/02 8:19
データベースコミットとトランザクションでふざけるのをやめよう
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
記事は、深い呼び出しスタック内やヘルパーメソッド内に手動の
commit() 呼び出しを配置するとトランザクションの原子性が破綻し、データ更新に失敗することを警告しています。厳格な一貫性の維持責任は、 désormais フレームワークから開発者へと移行しています。具体的な故障パターンには以下があります:
- 「隠れた敵」:
などのメソッド内部にある内部のDBAccess.create_main_records()
呼び出しが、トランザクションコンテキストマネージャーを上書きするケース。commit() - 「サイレントフレンドライ」というシナリオ: DB レイヤ外にデータベースモデルを渡すことで、暗黙的なコミットとサイレントな書き込み(例:モデルインスタンスの属性を設定すること)を引き起こす場合。
- 「ミルクラン」の例: パーシステントエンティティを変更する際、自動コミットが無効化(コメントアウト)されていることでデータが失われるケース。
これらの問題を防止するためには、すべての手動 DB セッション、トランザクション、コミット、クエリ、および DB モデルインポートは、DB 抽象化レイヤ内で維持する必要があります。原子的多重書き込みはヘルパーに分割されるのではなく、単一の関数内で実行されるべきです。AST をベースにしたカスタムテストまたはリンター(例:flake8)を使用して「手動コミット禁止」ルールの強制を行い、
session.commit() や独立した commit 呼び出しを検出し、レイヤ外での DB セッション/トランザクションへのアクセスをブロックしてください。違反された場合は、マージ前に人間によるコードレビューをトリガーする必要があります。さらに、CI/CD パイプラインに LLM を統合し、静的ツールで見過ごされうる微細なモデルの誤用(例:ドメインモデルではなく DB モデルを返すこと)を検出することで、DB 抽象化レイヤがすべてのトランザクションとコミットを担当することを確保し、原子性を維持しつつ将来のリファクタリングの複雑性を回避してください。本文
トランザクションとコミットの制御:データベース抽象化層を守るための原則
数ヶ月の仕様書策定を経てステークホルダー全員から承認を得ましたが、移行すべきすべてのコード片を Notion のデータベースに登録する作業が残っています。最初の PR(パッチリクエスト)作成直後、衝撃的な発見がありました。
「まさにトランザクションという名の服を着ながら、コミットが行われているではありませんか。」
ORM は強力ですが、本稿の主題は ORM への愚痴ではありません。パラメータ付き SQL ステートメントやクエリビルダーについても触れません。今回の真のテーマは、以下の誤りです。
- コードの組織化と抽象化を過度に複雑にする
- トランザクション内で包装されたコードが原子性(Atomicity)を失うこと
隠れた敵:サンプル・エビデンス
以下は説明的なコード例です(詳細なセッション作成などは省略)。
1. 隠れた敵:多段階のコミット
class DBAccess: @staticmethod def create_records(records: List[DomainModel]): # すべてのレコードを単一のトランザクションとして扱う必要があるが、 # ヘルパー関数内で `commit()` が実行されているため、全体で囲まれない。 with transaction(): for r in records: DBAccess.create_main_records(r) # 内部で commit を行う # ここから別のトランザクションが開始され、原子性が担保されない DBAccess.create_details_records(r) DBAccess.create_records(recs)
- 問題点: トランザクションコンテキストマネージャから 2 レベルも離れて手動コミットが行われる。
- 結果: メイン処理と詳細処理が1 つのトランザクションとして動作する事実を誰も認識できず、「神の慈悲」に任せてしまう。
2. 沈黙する両面派の友人:自動コミットの罠
class DBAccess: @staticmethod def fetch_records(ids: List[int]) -> List[DBModel]: db_models = session.query(DBModel).filter(DBModel.id.in_(ids)).all() return cast(List[DBModel], db_models) with transaction(): db_models = DBAccess.fetch_records(ids) db_models[0].yo_mama_fat = True # 値セットのみだが、実際には DB 書き込みが発生 # コンテキストマネージャ退出時に自動的にコミットされる(意図せず)
- 問題点: ドメインモデルと同様に扱い、プロパティ設定を行ったように見えるが、裏側でデータベースへの書き込みがトリガーされる。
- リスク: コードの意図と実際の副作用(DB 操作)が乖離する。
3. 父親のミルク調達:手動コミットの欠如
class DBAccess: @staticmethod def fetch_records(ids: List[int]) -> List[DBModel]: db_models = session.query(DBModel).filter(DBModel.id.in_(ids)).all() return cast(List[DBModel], db_models) # トランザクションがコメントアウトされており、手動コミットもない db_models = DBAccess.fetch_records(ids) db_models[0].yo_mama_fat = True # リクエスト終了後にデータは消え去る(トランザクション未確定またはロールバック)
- 問題点: 自動コミットなし、トランザクション設定なし。
- 結果: データの喪失(父親がミルクを買いに出かけ戻ってこないようなもの)。
責任は誰にあるのか?
フレームワークやビジネス要件のせいにするのは禁物。責任はプログラマーのみにあります。
- コードの状態に対する免責条項が適用されることは稀です。
- ランダムなコミットよりも悪質な状況はいくつもありますが、今回のケースでは致命的です。
バグを直すのはあなたの人生にかかっています。これが数ヶ月間続いた理由でもあります。
私たちは何を学んだのか?(教訓)
以下の原則を心に刻みなさい:
- 塩を撒くな: データベースセッションやトランザクションを適当に散らばせないでください。燃え尽きる運命があります。
- 境界を守れ: ドメインモデルを渡す際、データベース層の入出のみを使用しなさい。
- 触らないこと: トランザクション、コミット、クエリはデータベースアクセス層の外で扱わないこと。見ることさえ禁止します。
- 手動コミット禁止: 特にコンテキストマネージャやデコレータを使用している場合、手動コミットは厳禁です。
- 原子性の担保: コードをヘルパーに分割せず、1 つの関数でマルチ書き込みを行うべきです(3 行程度の INSERT など)。
- 重複記述(DUI)によって原子性が可視化され、隠れた敵は生まれなくなります。
これらの教訓を実行する方法
1. AST 解析による制御
カスタムテストやリンターを使用して、以下のルールを強制します。
- ❌ コミットを手動で行うことを禁止。
- ❌ データベースセッションをアクセス層の外で使用することを禁止。
- ❌ トランザクションをアクセス層の外で使用することを禁止。
- ❌ ドメインモデルのインポートをアクセス層の外から行うことを禁止。
AST 解析による検出(Python)
class TestDBBoundaries: def test_no_manual_commits(self): violations = [] for path, tree in parsed_source_files(): for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func # session.commit() の場合 if ( (isinstance(func, ast.Attribute) and func.attr == "commit") # commit() の場合(変数定義等も含む) or (isinstance(func, ast.Name) and func.id == "commit") ): violations.append(f"{path}:{node.lineno} 手動コミット commit()") assert not violations, "\n".join(violations)
2. flake8 を使用した禁止事項
AST パースの結果を
flake8 のカスタムチェックとして拡張する方法です。
import ast class BanManualCommits: def __init__(self, tree): self.tree = tree def run(self): for node in ast.walk(self.tree): if not isinstance(node, ast.Call): continue func = node.func if ( (isinstance(func, ast.Attribute) and func.attr == "commit") or (isinstance(func, ast.Name) and func.id == "commit") ): yield node.lineno, node.col_offset, "DB001 手動コミット commit()", type(self)
または setup.cfg
への登録例:pyproject.toml
[project.entry-points."flake8.extension"] DB0 = "flake8_db_boundaries:BanManualCommits"
3. AST と flake8 をどちらにするか?
| 選択基準 | 推奨ツール |
|---|---|
| コードベース全体のルールを見渡す必要がある場合 (共有リンター構成を変えられない、他チームの悪いコードも正しく指摘したい) | AST テスト |
| 標準的なツールである理由から (エゴを壊したくない、導入コストが低い) | flake8 / リンター |
4. LLM のサポートを組み合わせて使用する場合
AST や
mypy では戻り値の注釈が嘘になるため、または cast() で型を偽装している場合(「両面派の友人」)、LLM が有効です。
目的: データベース層がドメインモデル以外のものを返さないことを禁止する。
- CI/CD パイプライン内で LLM を実行。
- 決定論的なスクリプトでデータベースアクセス層の公開関数・メソッドをダンプし、LLM に質問:「何か DB モデルを返しているものはないか?ドメインモデルではなく。」
判定フロー:
/yes
: 人間によるレビューへ進み。maybe
: 「Go」に進み、給料を受け取る! 🍀no
結論
我慢しがたいと感じる方もいるでしょうが、最も学びとして残すべき原則は一つです。
データベース抽象化層がコミットとトランザクションを所有している。
それ以外すべては、この原則を誤解するために導入された回避策(ワークアラウンド)に過ぎません。
ランダムなコミットを発見した後、数ヶ月のリファクタリングを行う必要はありません。原則に従って修正すればよいのです。
必読書:
- 『ドメイン駆動設計——ソフトウェアの核心にある複雑性に挑む』