はじめに
こちらは バイセルテクノロジーズ Advent Calendar 2024 の4日目の記事です。 昨日は尾沼さんによる国内ECモール連携が主であるシステムで、グローバルなECモール連携を実現する際に生じた問題とその解決方法でした。
こんにちは!テクノロジー戦略本部 開発2部 EXSチームでバックエンドエンジニアをしている野口です。
私たちが開発しているプロダクト「EXS」では、ユーザーが注文情報を画面上から編集できる機能を提供しています。この編集画面において、操作手順次第で更新した内容が消えてしまうロストアップデートの問題に直面していました。
特にEXSでは非同期なデータ連携が頻繁に走る都合上「ユーザ同士」のみならず、「ユーザとシステム間」の書き込みも考慮したデータの整合性を担保する必要がありました。
本記事では、このような非同期処理が頻発するアプリケーションにおける編集画面の競合問題に対する解決方法の一例として、ハッシュベースの楽観ロックを使用した方法ご紹介します。
システム概要:EXSとは?
EXSは複数のECサイトへの出品・受注業務を一元管理するWebアプリケーションです。
代表的なデータ連携のフローを示します。
まずEXSから出品したいモールに向けて「①同時出品」を行います。その後、商品がモールから購入されると「②受注」が発生します。
EXSは受注連携を行ったのち、購入された数量分商品の在庫数を減少させ、同時に出品中のモール全てに「③在庫連携」を実行します。
これら全てが非同期に実施されています。
他にも「発送連携」や「支払い連携」等も注文領域における非同期処理として存在します。
背景
編集画面におけるロストアップデート問題
まずはEXSの注文編集画面の基本的な構成について説明します。
注文情報はフォーム形式で表示され、ユーザーは必要な項目を更新した後、保存ボタンを押すことで一括して更新を行うことができます。この画面の特徴として、配送情報の追加・削除を含む、注文に関連する大半の情報を一箇所で編集できる点が挙げられます。
しかし、この一括更新の仕組みにおいてロストアップデートが発生していました。具体的なケースを下記で示します。
ケース1: ユーザ同士の競合
ケース2: ユーザとシステムの競合
このような更新の消失は業務上問題となるため、適切な排他制御の実装が必要不可欠でした。
排他制御の要件と課題
求められていた要件
非同期処理によるデータ連携を止めないこと
競合が発生しても、非同期処理によるデータの更新は阻害されないような仕組みが求められていました。
理由としては、EXSで走っている非同期処理の中にはリアルタイム性が求められる処理(e.g. 在庫連携)や、システムの制約上リトライが難しい処理が複数存在するためです。
一方、編集画面における操作には以下の特徴がありました。
- ユーザ操作については常にリトライ可能
- 同一注文に対する同時編集は業務フロー上極めて稀
- 編集作業に数分以上かかる場合(電話応対しながらの操作など)も存在する
以上を考慮し、編集画面における排他制御の方式としては「楽観ロック」の方針を前提としました。
楽観ロック導入における課題感
更新範囲の特定と変更検知
EXSにおいて、非同期処理による更新範囲は様々です。
例えば、配送連携であれば注文に紐づく配送情報を更新しますし、支払い連携であれば支払い情報を更新します。
これらの変更検知を行う方法として主に「必ず更新のかかる代表テーブルを一つ定めて、対象テーブルのデータ更新状況のみ監視する方法」と「更新されうるデータ全体を監視する方法」があります。
前者は集約等の概念をアプリケーションの設定として導入していれば実現は容易ですが、EXSでは導入していません。
よって更新されうるデータ全体を監視し、変更検知を行う必要がありました。
UXとの両立
楽観ロックを採用した場合、データの更新競合が検出されると、後から更新を試みた側は操作のやり直しを求められます。
画面からの編集操作においては、これは入力済みのデータをすべて再入力する必要があることを意味します。
そのため、変更検知の範囲を広く設定しつつも、必要最小限の項目に絞ることで、不要な競合の発生を防ぐ方法を模索する必要がありました。
方針の検討
編集画面の整合性を保つため、主に以下の3つの方針を検討しました。
1. versionカラムの追加(不採用)
一般的な楽観ロックの実装方法の一つです。
更新が発生するたびにインクリメントされるversion
のようなカラムをテーブルに追加し、取得時と更新時のversion
を比較することで整合性を担保します。
ORMが機能として提供してくれている場合も多く、我々が利用しているGORMでもプラグインが提供されています。更新時インクリメントする機構もDBのイベントトリガを使えば実装は容易です。
しかし、以下の理由で私たちのケースにおいては採用を見送りました。
- 要素数の変動への対応
- 編集時に配送情報等の要素数が増減した場合に、単純な
version
の比較だけで整合性担保を賄えない。別のロジック(子の要素数変更に伴い親のversion
も変更する仕組みの導入や、要素数の変更検知等)が必要
- 編集時に配送情報等の要素数が増減した場合に、単純な
- 変更検知範囲の柔軟性
- 差分検知の範囲を変更する際にスキーマ変更が必要
- 変更検知のスコープがテーブル構造に引きずられてしまう(フラグ系の項目などの「システムからは更新されるが、画面からは更新されない」ような値が更新された場合でも
version
がインクリメントされてしまう)
2. 更新時間使った比較(不採用)
updated_at
等のタイムスタンプを比較する方法です。
データ取得時点のupdated_at
と現在のupdated_at
を比較し、変更の有無を検知します。
こちらも不採用理由は1とほとんど同様です。
updated_at
は既に原則全てのテーブルに存在しており、スキーマ変更の工数がかからない点は優位ですが、採用には至りませんでした。
3. ハッシュ値を使った検証(採用)
最終的に採用したのが、ハッシュ値を用いた検証です。 監視対象データをハッシュ化し、クライアントとサーバー間でそのハッシュ値を共有します。 更新時にハッシュ値を検証し、一致すれば更新を通すという方針です。
主な採用理由は下記です。
- 柔軟な変更検知範囲
- ハッシュ化対象をアプリケーションロジックとして自由に定義可能
- データベーススキーマに依存せず、検知範囲の追加・変更が容易
- 将来的に特定の項目ベースな変更検知も実装可能
- 要素数変動への対応
- オブジェクト構造全体のハッシュ化により、要素数の変動を検知できる
- 低い実装コスト
- アプリケーションロジック内で完結するので、既存構造への影響を最小限に抑制
具体的なアプローチ
ここまでの話を踏まえて、以降は編集画面における楽観ロックの具体を示します。
サンプルコードはEXSで利用しているGo
で記述します。
実装の全体像
楽観ロックの実装に伴い修正したのは、番号を振った下記の処理です。
① ハッシュキーを生成
② ハッシュキーを返却
③ ハッシュキーをクライアント側で保持
④ 更新時、ハッシュキーをリクエストに含める
⑤ ハッシュキーの検証
以降の章で具体的に見ていきます。
ハッシュキーの生成
ハッシュキーの生成ロジックは注文オブジェクトのメソッドとして実装しています。
type Order struct { ID int OrderNumber string // その他諸々のカラム CreatedAt time.Time UpdatedAt time.Time Shippings []*Shipping } // 注文情報からハッシュ値を計算 func (o *Order) CalculateHash() (string, error) { // 変更検知したいオブジェクトを絞り込む処理 order := extractOrderForHash(o) jsonData, err := json.Marshal(order) if err != nil { return "", err } // ハッシュ化 hasher := fnv.New64a() _, err = hasher.Write(jsonData) if err != nil { return "", errs.WrapSystemError(err) } return fmt.Sprintf("%x", hasher.Sum64()), nil } func extractOrderForHash(o *Order) *Order { shippings := make([]*Shipping, len(o.Shippings)) for i, v := range o.Shippings { shippings[i] = &Shipping{ ID: v.ID, UpdatedAt: v.UpdatedAt, } } return &Order{ ID: o.ID, UpdatedAt: o.UpdatedAt, Shippings: shippings, } }
まず、変更検知したいオブジェクトを絞り込んでいます。
サンプルは注文情報と配送情報を差分検知対象としたい場合の例です。各オブジェクトのID
とUpdatedAt
をpick upしています。
EXSでは注文を起点に多数のデータが紐づいていますが、設定情報系(支払い方法などシステム側が用意する情報)に関しては検知の対象外としています。
また、変更検知したい差分をカラム単位で絞ることもできます。例えばUpdatedAt
ではなく、もっと具体的に発送日時
など、細かくデータを指定することで、プロパティベースで差分検知対象のスコープを狭めることも可能です。
次に、ハッシュ化を行います。
ハッシュ化ロジックに関しては、衝突が発生しにくい一意な文字列が生成できれば十分です。
今回は他のハッシュ関数と比較して高速かつ計算コストの低いhash/fnv
パッケージを採用しました。
ここで生成したハッシュ値を、クライアントに返却し、保持してもらうという形です。
更新時にこのハッシュ値をAPIサーバに投げてもらいます。
ハッシュキーの検証
検証時はクライアントにリクエストしてもらったハッシュキーと、現在のDBから取得したハッシュキーを比較します。
サンプルコードを示します。以下はハッシュ検証周りのロジックを極めて簡易的に示したものです。
func (s *UpdateOrderService) Run(ctx context.Context, input *UpdateOrderInput, hash string) error { var o model.Order // DBから注文データを取得 // ハッシュ値の検証 isVerified, err := o.VerifyHash(hash) if err != nil { return err } // 検証失敗時はエラーを返す if !isVerified { return errors.New("注文情報が変更されたため更新内容を保存できません。このページを再読み込みしてから再度操作してください。") } // DBへ注文データを書き込み return nil }
検証に成功した場合は更新を行い、失敗した場合はユーザへエラーを返します。
検証ロジックが以下です。 ハッシュ生成コードを再度呼び出して、値を検証しています。
func (o *Order) VerifyHash(hash string) (bool, error) { h, err := o.CalculateHash() if err != nil { return false, err } return h == hash, nil }
以上がロックの一連の流れになります。
結果・成果
この仕組みを導入した結果、編集画面におけるロストアップデート問題は解消されました。
同時編集はもちろん、バッチ処理による変更やデータ修正の際にも差分を検知してくれるため、「修正差分が消失した」などの不具合は発生していません。
さらに、差分検知の範囲も適切に設定できているおかげか、ユーザからの不満の声も現在のところ上がっていない点も良かったと考えています。
今後の展望
今後の展望としては、ユーザーからのフィードバックに応じて、差分検知のスコープを調整予定です。
さらに、編集の履歴を元に変更差分をマージする機能などを導入できると理想なのでチャレンジしてみたいと考えています。その際は今回の仕組み自体不要になる可能性もありますが、比較的切り離しやすい実装にはなっているので、移行コストもそこまで発生しないと踏んでいます。
最後に
いかがでしたでしょうか。
本記事では、非同期処理が頻発するシステムにおける編集画面の整合性確保の一例として、ハッシュベースの楽観ロックによる解決例を紹介しました。
この記事が、少しでもデータ整合性担保について悩んでいるエンジニアの手助けになれば幸いです。
最後に、バイセルでは一緒に働くエンジニアを募集しています、興味がある方は、以下よりご応募ください。 herp.careers 明日の バイセルテクノロジーズ Advent Calendar 2024 は 小島さんによる 「長期的に活躍する!オンボーディング期間の重要性とやるべきことについて」です。 お楽しみに!