本稿はJCB Advent Calendar 2023の12月13日の記事です。
はじめに
DXテックG アプリチームの渡辺です。
本記事では、アプリ開発において排他制御を実装した際に学んだことについてまとめたいと思います。
読んでくれた方の参考に少しでもなれば幸いです。
排他制御とは
共有しているデータへの同時更新で不整合が生じることを防ぐため、あるトランザクションが共有データを更新している時は他トランザクションからは更新できないように制御することです。
排他制御の方法には、大きく分けて楽観的排他制御と悲観的排他制御(以降、楽観排他と悲観排他で表記)の2種類があります。
楽観排他
楽観排他とは、共有データに対する複数のユーザーによる同時更新はあまり発生しないという前提で行う排他制御のことです。
更新対象のデータが取得時と同じ状態であることを確認してから更新させることでデータの整合性を保証します。
状態を判別するカラムには更新日時のカラムも使用できますが、同一時間での操作も考慮するとバージョンカラムを使う方法がより確実です。
悲観排他
悲観排他とは、共有データに対して複数のユーザーによる更新が頻繁に発生するという前提で行う排他制御のことです。
更新対象のデータを読み取る際にロックをかけることで他のトランザクションから更新されないようにします。
ロックされたレコードはトランザクションが完了するまで他のトランザクションから更新させないことでデータの整合性を保ちます。
各手法のメリットとデメリット
悲観排他では次の更新処理を行うためにはロックの解放が必要となります。
しかし、更新処理の途中でブラウザが閉じられたり端末のシャットダウンが発生することも考慮すると、確実にロックを解放するための制御は複雑になります。
楽観排他ではそのような複雑な制御は必要にならないものの、更新のための操作が同時間帯に複数行われていた場合には問題が発生します。
後から行われた更新操作はデータが取得時から変わっているためエラーとなり、そのユーザーの操作は無駄になってしまうためです。
なので、たとえやり直すことになってもユーザーにあまり手間がかからないようなケースにおいての使用が向いています。
実装サンプル
本記事ではやり直しが簡単な更新処理を提供するアプリで使える方法として、実際に楽観排他の実装方法について紹介してみたいと思います。
準備
今回は Nestjs で「inventory-app」という商品の個数を管理するプロジェクトの実装例を紹介します。
Nestjs とは Node.js のフレームワークであり、モジュール・コントローラ・サービス の組み合わせによりアプリケーションを構築します。
また、 OR マッパーである TypeORM と組み合わせて DB 操作を簡単に行うことができます。
まずは、準備として次のコマンドを実行します。
nest new inventory-app
実行すると使用するパッケージマネージャー(yarn
等) を聞かれるので選択してください。
- ライブラリ
@nestjs/common
:10.2.10
@nestjs/typeorm
:10.0.1
typeorm
:0.3.17
バージョンカラムを用意
テーブル定義となるエンティティを用意します。
TypeORM で用意されている VersionColumn はレコードの更新時に自動で値がインクリメントされていきます。
inventory.entity.ts
import { Column, Entity, PrimaryGeneratedColumn, VersionColumn } from 'typeorm'; @Entity('inventory_item') export class InventoryItem { @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; @Column() price: number; @Column() number: number; @VersionColumn() version: number; }
今回は割愛しますが TypeORM のマイグレーション機能により上記エンティティの内容をDBに適用してください。
楽観排他による更新処理を作成
リクエストに用いる DTO を用意します。
inventory-item.dto.ts
import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; export class InventoryItemDto { @IsString() @IsNotEmpty() id: string; @IsNumber() @IsNotEmpty() number: number; @IsNumber() @IsNotEmpty() version: number; }
リクエストを受け付けるコントローラを用意します。
inventory.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common'; import { UpdateResult } from 'typeorm'; import { InventoryService } from 'src/services/inventory.service'; import { InventoryItem } from 'src/entities/inventory.entity'; import { InventoryItemDto } from 'src/dto/inventory-item.dto'; @Controller('inventory') export class InventoryController { constructor(private readonly inventoryService: InventoryService) {} @Get() async findAll(): Promise<InventoryItem[]> { return await this.inventoryService.findAll(); } @Post('purchase') async update( @Body() inventoryItemDto: InventoryItemDto, ): Promise<UpdateResult> { return await this.inventoryService.purchase(inventoryItemDto); } }
処理の本体を定義するサービスを用意します。
サービス内の setLock の部分によりバージョンカラムによる判定が行われます。
inventory.service.ts
import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository, UpdateResult } from 'typeorm'; import { InventoryItem } from 'src/entities/inventory.entity'; import { InventoryItemDto } from 'src/dto/inventory-item.dto'; @Injectable() export class InventoryService { constructor( @InjectRepository(InventoryItem) private inventoryItemRepository: Repository<InventoryItem>, @InjectDataSource() private dataSource: DataSource, ) {} async findAll(): Promise<InventoryItem[]> { return await this.inventoryItemRepository.find(); } async purchase(inventoryItemDto: InventoryItemDto): Promise<UpdateResult> { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); // バージョンカラムによる楽観排他 const inventory = await this.inventoryItemRepository .createQueryBuilder('inventory') .setLock('optimistic', inventoryItemDto.version) .where('inventory.id = :id', { id: inventoryItemDto.id, }) .getOne(); if (!inventory) { throw new BadRequestException(); } const number = inventory.number - inventoryItemDto.number; if (number < 0) { throw new BadRequestException(); } // 更新 const result = await queryRunner.manager.update( InventoryItem, { id: inventory.id, }, { number: number, }, ); await queryRunner.commitTransaction(); return result; } catch (e) { await queryRunner.rollbackTransaction(); throw e; } finally { await queryRunner.release(); } } }
作成したコントローラとサービスをモジュールに登録します。
inventory.module.ts
import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { InventoryItem } from 'src/entities/inventory.entity'; import { InventoryController } from 'src/controllers/inventory.controller'; import { InventoryService } from 'src/services/inventory.service'; @Module({ imports: [TypeOrmModule.forFeature([InventoryItem])], controllers: [InventoryController], providers: [InventoryService], exports: [], }) export class InventoryModule {}
先ほどのモジュールをルートモジュールに登録します。
app.module.ts
import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { InventoryModule } from 'src/modules/inventory.module'; import { TypeOrmConfigService } from 'src/database/type-orm-config.service'; @Module({ imports: [ InventoryModule, TypeOrmModule.forRootAsync({ useClass: TypeOrmConfigService }), ], controllers: [], providers: [], }) export class AppModule {}
動作について
以上により、楽観排他による更新処理を実装できました。
アプリを起動 (yarn start
等) 、DBにデータを用意した上で Postman で API を実行してみます。
バージョンが一致している場合は次のようにレスポンスが返ってきます。
バージョンが一致していない場合はエラーレスポンスが返ってきます。
おわりに
ここまで記事を読み進めて頂きありがとうございました!
今回は実際の開発経験を通じて得た知識をまとめましたが、意外と簡単に楽観排他を実装できることが分かっていただけたと思います。
最後になりますが、JCB では我々と一緒に働きたいという人材を募集しています。
詳しい募集要項等については採用ページをご覧下さい。
本文および図表中では、「™」、「®」を明記しておりません。
記載されているロゴ、システム名、製品名は各社及び商標権者の登録商標あるいは商標です。