* 本記事は STORES Advent Calendar 2023 14日目の記事です
STORES でバックエンドエンジニアをやっている @ucks です。
Advent Calendar やろうぜとなって取り敢えず参加表明したものの、全然ネタが思い付かず。 どうでも良いことを書いてやろうと思ってたのに思いつかなかったので、真面目に仕事の話を書きます。
今回は、他のこともやりながらも、2年ほど前から着手していた、オーダー(注文)データの再設計と移行について書いてみようかなと思います。
背景
STORES ネットショップは2012年からサービスを開始していて、オーダーデータも当時設計された構造を元に、様々な機能を追加してきていました。
様々な機能の作りはじめは、知見も少なく、最適な設計をすることが難しかったこともあり、気が付けば、色んな所に情報が散りばめられた構造になってしまっていたり、処理が複雑になり、機能拡張が難しくなってしまった等の問題が出てきました。
そこで、2021年頃からオーダーのデータ構造を作り直そうということで、プロジェクトが開始されました。
移行の計画
まずは、どの様に新しいデータ構造に移していくかを考える必要があります。
サービスを止めて、一気にデータコンバートして切り替えると言うのも手ですが、数千万ドキュメント(レコード)のデータがあり数時間で変換できる量ではない(1件数十msだったとして何週間も…)と言うことと、データ構造の変更に合わせて修正する箇所も多くなり、想定外の問題が発生するリスクも高いので選択肢には入りませんでした。
ということで、今回は新しいオーダー用にMongoDBに新しいコレクション(RDBで言うテーブル)を追加し、新旧2つのオーダーに二重で書き込んでいくことにしました。 二重書き込み対応が完了した後、過去のオーダーも全てコンバートすることで、全てのオーダーデータの同期を取ります。
オーダーの構造を考える
今までの反省点やこれから追加したい機能等を考慮して、大雑把にオレの考えた最強のオーダーの設計を始めました。
具体的な構造や新機能に関わることは、ここでは多く語れませんが、今までは1つのオーダーに1つのデータしか入らなかった構造を複数入る前提の設計にしたり、その中身をMongoidとSTIの紹介で書いた様な事を組み込んで設計を行なっていきました。
例えば、STORES ネットショップだと物販やデジタルコンテンツ、電子チケット等、様々な種類のアイテムを販売することができますが、この部分をSTIを使って設計したり、決済方法もクレジットカード決済やコンビニ決済、他にも様々な決済方法がありますが、そういった箇所もSTIを利用して設計してみました。
旧オーダー | 新オーダー | |
---|---|---|
class OldOrder include Mongoid::Document embeds_one :old_payment_method field :transaction_id embeds_one :'***_payment_info' embeds_many :items def settle if credit_card_payment? settle_credit_card elsif konbini_payment? settle_konbini elsif ***_payment? : end def settle_credit_card PaymentServiceClient.new.capture end def settle_konbini : end end class OldPaymentMethod; end class ***PaymentInfo; end |
→ |
class NewOrder include Mongoid::Document embeds_many :new_payments embeds_many :items end class NewPayment include Mongoid::Document def settle raise NotImplementedError end end class CreditCardPayment < NewPayment field :transaction_id def settle PaymentServiceClient.new.capture end end class KonbiniPayment < NewPayment field :payment_number def settle : end end |
他にもより厳格にバリデーションを定義したり、税額計算や割引時の税率毎の按分計算等の複雑な処理や監査周りの課題等もいい感じにできる様な設計を行ったりもしているのですが、書き始めると長くなるので、今日は置いておいて…誰かがブログを書いてくれることを期待します。
データの変換
新オーダーの大まかな設計が終わった後は、旧オーダーのデータを新オーダーに変換するロジックを書き始めました。 変換ロジックを書いていくことで、旧オーダーのそれぞれの値は、新オーダーのどの値になるのかの認識を合わせる目的もありました。
また、オーダーにある情報は非常に多く、コードレビューで新旧オーダーのマッピングに漏れや誤りがないことを担保するのは難しい状況でした。 そこで、新オーダーから旧オーダーに逆変換するロジックもこの時に合わせて用意しました。 こうすることで、情報に漏れがなければ、旧オーダーから新オーダーに変換して、変換された新オーダーから旧オーダーに戻した時に同じデータが出てくるので、もし差分があれば漏れに気づくことができます。
他にも、今回設計した新オーダーは、いろいろな拡張性を考慮した結果、旧オーダーとは構造が大きく異なる設計になりました。
そのため、新オーダーと旧オーダーの2つを運用し、整合性を担保していくのは様々なコストがかかってしまいます。 早めに旧オーダーへの書き込みを停止させ、運用コストを減らしたいところですが、現状、多くのAPI等は旧オーダーからjson等にシリアライズしています。
今回用意した逆変換ロジックを利用すれば、旧オーダーへの書き込みを停止させても新オーダーから旧オーダー相当の形式に変換して、そこから既存のAPI等のシリアライザーに渡せば、今まで通りの結果を返すことができます。
もちろん、旧オーダーの形式では表せないデータ構造があるので、いずれは全てのAPIを新オーダーの形式に差し替える必要がありますが、マッパーを介すことで、新オーダーから旧オーダーのデータを作れる様になるので、旧オーダーを保存する必要がなくなります。
今回はこの様な理由で、変換と逆変換を行うマッパーを用意しました。
書き込み処理の追加
新旧オーダーのデータのマッピングがある程度完了したら、データのダブルライトの処理を書き始めました。 と言っても、旧オーダーへの書き込みを終えた後に、新オーダーに書き込みを行う処理を追加していくだけです。
ただし、いずれ旧オーダーへの書き込みを削除し、新オーダーのみに書き込みを行う様にするので、この書き込み処理はデータを変換するロジックを利用せずに、本来あるべき形で実装していきました。 この時は、新オーダーへの書き込みではどの様なエラーが出るか分からなかったので、全てハンドリングして、エラー通知のみを行い、握り潰す様にしました。
order = build_order order.reduce_stocks begin new_order = build_new_order # new_order.reduce_stocks rescue => e Sentry.capture_exception(e) end |
→ |
new_order = build_new_order new_order.reduce_stocks begin order = build_order # order.reduce_stocks rescue => e Sentry.capture_exception(e) end |
→ |
new_order = build_new_order new_order.reduce_stocks # begin # order = build_order # order.reduce_stocks # rescue => e # Sentry.capture_exception(e) # end |
新オーダー処理の追加 | オーダーの処理順を入れ替え | 旧オーダー処理の削除 |
また、決済処理や在庫処理の様に新旧両方で動かすと良くない結果になる箇所もあります。 その様な箇所では、片方動かなくしたり、片方のAPIの結果を受け渡す様な処理を書いて対応しました。
class CreditCardPayment < NewPayment include Mongoid::Document def settle # OldOrder側で処理済みなので # 二重決済になるのでコメントアウト # res = client.settle(params) # OldOrderからレスポンスを擬似的に生成 old = OldOrder.find(id) res = { transaction_id: old.transaction_id } update( transaction_id: res[:transaction_id] ) end end
ちなみに、処理を追加して動かしてみると、それなりにエラーや差分が発生する様な状況でした。
原因としては、新しく追加した新オーダー側の不備や考慮漏れといったものもありましたが、旧オーダー側の処理が不適切だったものが炙り出されたものもありました。
新オーダー側の考慮漏れに関しては、漏れているだけなので追加で実装すれば良いものが多く大きな問題はありませんでした。
旧オーダー側の問題については、既存の挙動を変更しないと対応できないものもあったり、セキュリティ的な問題が見つかったりしたので、過去のデータを精査したり修正したり、場合によっては法務確認してオーナーさんに連絡を取ったりと言った作業が発生し、時間を取られてしまいました。
過去データの移行
この作業は、書き込み処理を追加する前の過去のデータを、データ変換の項で用意したマッパーを利用して、新オーダーを作成する作業です。
この作業では、コンバートのペースを上げてしまうとDB負荷が高くなってしまう問題があったり、データ量が多く非常に時間がかかってしまう問題がありました。
そのため、特定の期間毎に、DB負荷が少ない時間帯を狙って少しずつ変換作業を行なっていきました。
こちらも書き込み処理と同じ様に、実際に動かしてみると、想定しているデータがないオーダーが見つかったり、入るはずのない値が入っていて、バリデーションエラーでデータが保存できない様な問題が発生したり、整合性の合わないデータが大量に発生する問題が発生したりしました。
想定しているデータがないものや整合性の合わないデータに関しては、なぜそのデータが発生したのかを調査したり、どの様な値で補完するべきか検討して、旧オーダーのデータを修正したり、場合によっては法務確認、オーナーさんへの個別連絡等の作業を行なっていきました。
order.payment_method => 'konbini' order.konbini_info => nil # コンビニ決済なのにコンビニ決済情報がない?
また、バリデーションエラーに関しては、バリデーションを無視して保存することもできますが、どの位エラーになるデータがあるのか、どの様な形でエラーになるのかを把握するために、1つ1つ迂回する対応を入れていきました。
# バリデーション実装前に入ってしまったデータがあるのでバリデーションエラーを回避する (良い子は真似しない様に) new_order.name.define_singleton_method(:length) { 100 } if new_order.name.length >= 100
不整合も内容によっては、無視して良いレベルの差分もあったので、1つ1つこう言うパターンの時は無視する等(特定の項目のnilと空文字列の差分は無視するとか、 String#trim
した結果が一緒なら無視するとか)の処理を書いていきました。
old_order.name => '名前 ' new_order.name => '名前'
ちなみに作業を開始した当初のログ等を漁ると1週間分のデータで5万件ほど何かしらの不整合が発生している様な状況でした。 データの同期を行なって、差分を確かめて、発生していたズレはどこが原因なのかを調査し、原因を修正し、またデータの同期を行い、差分を確かめてという作業を延々と繰り返していきました。
現状とこれから
ここまで、オーダーデータの再設計と移行について、書いてきましたが、実はこのブログを書いている時点で完遂できていません。 現状は、データの同期がほぼ終わって差分がない状態までやっと辿り着くことができました。 まだ、完遂はしていませんが、この作業を行ったことで、オーダーデータの不整合が正され、不適切なロジックが見直され、より堅牢なシステムになった状態です。
まだ、API等は旧オーダーを元にした設計になっているため、新オーダーで想定している拡張を提供するには至っていません。 これから、様々なロジックを旧オーダーから新オーダーに差し替えるという作業を行なっていきます。
反省
正直、このプロジェクトを開始した時は、オーダーはお金も絡む部分なので、それほど不整合もなく、ある程度簡単に移行できるのではないかと思っていました。
実際に箱を開けてみると思った以上に大変で、想定以上に時間がかかってしまったなと思っています。
かかった時間に対して、やって良かったのか、悪かったのかについては、まだ道半ばなので、何とも言いにくいところではありますが、オーダーの状態としてはより健全な状態になったとは思っています。
あと、色々書けないこともあり、読み物として面白味も少ない、個人的に納得いかないブログになってしまったのも反省点です。 🙄
明日12/1(金)の STORES Advent Calendar 2023 は takeuchi と みっちゃん です!