【NoSQLの落とし穴】Firestoreのトランザクションについて整理する

## NoSQLの落とし穴とは

みなさん、NoSQL使ってますか？
NoSQLはRDBMSに比べて、スキーマを厳密に定義せずとも手軽に導入・利用できることから、多くのサービスに利用されている技術だと思います。

特にFireStoreはFirebaseが提供(今はGCPが提供,と書くのが正しいのかな？)しているサービスで、幅広く使われている印象を受けます。

ただ手軽に導入できる反面、落とし穴もあります。

それは「トランザクション問題」です。

今回は在庫管理システムを例に、その落とし穴と対策について考えてみます。

## 課題

Firestoreを使って在庫管理システムを構築する際、複数のユーザーが同時に在庫に関連する操作を行うことが考えられます。

この場合、在庫数に不整合が生じる可能性があるため、トランザクションを活用してデータの一貫性を保つ方法についてまとめていきます。

## 要件
在庫管理システムでは以下の操作が行われます。

- 商品の仕入れ -書き込み
- 商品の販売 -書き込み
- 商品のキャンセル -書き込み
- (アプリ画面上の)表示 -読み込み

具体的には以下の通りです。

![在庫管理アプリにおける読み込み/書き込み](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/579035/4fac7b46-4b13-5b3e-373b-625ad02b656b.png)



例えば画面表示中に何らかの書き込み処理が行われた場合、「**表示されている在庫数**」と「**実際の在庫数**」に差分が生じたりします。
このケースはあくまで表示するだけなので致命的にはなりませんが、販売とキャンセル等の書き込み処理が同時に走った場合はどうなるでしょうか。

以下、具体例を考えてみます。

#### 同時発生が起こらない場合
 
- **初期状態**: 商品Aの在庫数 100

1. **10:00 - 仕入れ**
   - 操作: 50個仕入れる
   - 在庫数: 100 → 150

2. **10:01 - 販売**
   - 操作: 30個販売する
   - 在庫数: 150 → 120

3. **10:02 - キャンセル**
   - 操作: 10個キャンセル（返品）
   - 在庫数: 120 → 130

この場合は問題ありません。

#### 同時発生の具体例

- **初期状態**: 商品Aの在庫数 100

- **10:00 - 販売**
  - 操作: 20個販売
  - 在庫数: 100 → 80 ?

- **10:00 - 仕入れ**
  - 操作: 50個仕入れ
  - 在庫数: 100 → 50 ?

- **10:00 - キャンセル**
  - 操作: 10個キャンセル
  - 在庫数: 100 → 110 ?

上記ケースのように同時に行われた場合、単純に差し引きしてデータ登録するとズレが生じてしまいます。

例えば各処理の始めの在庫数は「100」で、全ての処理が完了した時の結果は100-20+50+10=140になります。

ただし、最後にキャンセル処理が終わって110を登録してしまった場合、結果として30個の在庫数のずれが生じてしまいます。

これはまずいですよね。。

#### 解決策

この在庫数のずれを解決する方法としてトランザクションという仕組みがあります。

例えば仕入れ処理のトランザクション開始時、在庫数が100だったけど書き込み時に80になっていた場合、失敗とみなしロールバックされます(つまり仕入れ処理が無かったことになる)。

> 参考
https://gihyo.jp/dev/serial/01/db-academy/000201

ちなみにMySQLなどのRDBMSではトランザクションがサポートされており、フレームワークによってはトランザクションをあまり意識しなくてもデータの一貫性が保てたりします。
（あとは複数テーブルに跨った処理なども1つのトランザクションで可能だったり、、）

ただFirestoreではある程度開発者がトランザクションを意識して実装する必要があったり、同一コレクション内でしかトランザクションを実行できないといった制約もあったりして、考えることが多いです。。

## Firestoreにおけるデータ構造

実際に在庫数をFirestoreで管理することを想定して実装してみます。

Firestoreでは各商品の在庫を管理するために、`/stocks`というコレクションを使用し、各商品の在庫情報をドキュメントで管理します。

各ドキュメントには`totalStock`というフィールドがあり、これがその商品の現在の在庫数を示します。

例えば、productAの在庫情報を保持するドキュメントは次のようになります。

```json

{
  "productId": "productA",
  "totalStock": 100 # ここが読み込まれたり書き込まれたりする
}

```

## トランザクションを使った在庫管理

Firestoreではトランザクションを使用することで、同時に複数のユーザーが在庫操作を行った場合でも、データの一貫性を保つことができます。

またトランザクションは、**読み取ったデータが変更されていないか確認し、変更があればリトライする**機能を持っています。
これにより、データの不整合が発生するリスクを低減できます。


以下、**仕入れと販売処理が同時に起こりうる**ことを考慮した実装を考えていきます。

### 仕入れ処理の実装例
まずは、商品を仕入れる処理を実装してみましょう。

```typescript

import { firestore } from "./firebase"; // 別ファイルでclass等を用意

async function addStock(productId: string, amount: number): Promise<void> {
  const productRef = firestore.collection('stocks').doc(productId);

  try {
    await firestore.runTransaction(async (transaction) => {
      const productDoc = await transaction.get(productRef);

      if (!productDoc.exists) {
        throw new Error(`Product with ID ${productId} does not exist!`);
      }

      const currentStock = productDoc.data()?.totalStock || 0;
      const newStock = currentStock + amount;

      transaction.update(productRef, { totalStock: newStock });
    });

    console.log(`Successfully added ${amount} units to ${productId}`);
  } catch (error) {
    console.error("Transaction failed: ", error);
  }
}
```

このコードでは、指定された商品の在庫数を増やす処理をトランザクション内で行っています。

トランザクション内でドキュメントを読み取り、そのデータを元に新しい在庫数を計算し、更新します。

### 販売処理の実装例
次に、商品を販売する処理を実装します。

```typescript

async function sellStock(productId: string, amount: number): Promise<void> {
  const productRef = firestore.collection('stocks').doc(productId);

  try {
    await firestore.runTransaction(async (transaction) => {
      const productDoc = await transaction.get(productRef);

      if (!productDoc.exists) {
        throw new Error(`Product with ID ${productId} does not exist!`);
      }

      const currentStock = productDoc.data()?.totalStock || 0;

      if (currentStock < amount) {
        throw new Error(`Not enough stock for product ${productId}`);
      }

      const newStock = currentStock - amount;

      transaction.update(productRef, { totalStock: newStock });
    });

    console.log(`Successfully sold ${amount} units of ${productId}`);
  } catch (error) {
    console.error("Transaction failed: ", error);
  }
}
```

こちらのコードでは、在庫が足りない場合にエラーをスローし、トランザクションを中断するようにしています。

## トランザクションの利点と考慮点
トランザクションを使用することで、複数のユーザーが同時に在庫を操作しても、データの整合性を保つことができます。ただし、以下の点に注意が必要です。

- リトライの可能性:
  - トランザクションはデータが変更されている場合に自動でリトライされる
  - ただし、これにより一部のトランザクションが予想より時間がかかる可能性がある
- デッドロックの回避
  - 同じデータに対して複数のトランザクションが同時に実行されると、デッドロックが発生する可能性がある
  - これは、トランザクションの設計を慎重に行うことで回避できる

例えばFirestoreの場合、数値の増減には[increment](https://firebase.google.com/docs/firestore/manage-data/add-data?hl=ja#increment_a_numeric_value)というオペレーションが使えます。
そのため今回のようなシンプルな在庫数管理であれば、トランザクション処理せずともincrementで要件を満たすことができます。

また同時実行されるトランザクションの処理数が多かったり、トランザクション1つあたりの処理時間が長かったりするとデッドロックが発生することもあります。

なので**トランザクション数は必要最低限**にし、**トランザクション内の処理はできるだけシンプルにする**ことが大切です。

## まとめ
Firestoreを使った在庫管理システムでは、トランザクションを活用することで、データの整合性を保ちながら複数のユーザーによる同時操作をサポートできます。

特に、在庫管理のようなクリティカルなデータを扱う場合には、トランザクションの正しい理解と実装が重要です。