アジェンダと自己紹介

照井寛也氏(以下、照井):「POSレジとGo」というタイトルでさっそく発表します。今回話す内容ですが、チャネルとgoroutineを、実際のビジネスロジックでどのように使っているかの事例の共有と、そこから得た学びを共有します。

アジェンダとしてはこのようなかたちになっています。まず自己紹介をし、Showcase Gigが提供する次世代店舗プラットフォーム「O:der(オーダー)」とPOSレジの関係性、そこからチャネルとgoroutineを用いたPOS連携開発について話します。あとはそれらを開発実装したうえでの学びを共有します。

というところで、さっそく自己紹介にいきます。照井寛也と言います。Showcase Gigには2021年2月よりジョインし、POS連携サービスのサービスのバックエンドエンジニアを担当しています。

この会社に入ってからGoを実際に実務として書いたので、Go歴は半年ちょっとではありますが、ありがたいことに、いろいろな機能追加、特に、POS連携サービスの機能追加や設計を行えています。今回はそこのPOS連携の機能追加に関してちょっとお話します。

POSレジとはなにか

さっそく「O:der」とPOSレジについて説明に入ります。「O:der」との関係性については、POSレジを紹介した後に説明します。

まずPOSレジですが、みなさん、どうでしょうか。POSレジというものを聞いたことがあったり、馴染みある方はいますでしょうか。ちなみに自分はこの会社に入ってから、やっと仲良くなれたような気がしています。

役割としては、「何の商品を・いつ・いくらで・何個販売したか」という、いわゆる売上管理システムだったり、販売管理システムをPOSシステムと言っていて、そこと連携しているレジになります。また「何の商品を・なんていう名前で・いくらで売ってるか」という、マスタを管理してたりします。

イメージとしては、飲食店によくある、いわゆるあのレジです。(それと)このPOSシステムが連動してるものというところになっています。種類としてはパソコン型だったり、ターミナル型、あと最近タブレット型もよく目にすることがあります。

このPOSレジが、「O:der」とどういう関係性かというところですが、双方向に関係性があります。このPOSレジからマスタを「O:der」に同期するところと、あとは「O:der」からの注文や会計を、POSレジに連携している関係性があります。

今回は、それらの間に立っている、POS連携サービス、自分が所属しているチームで開発していますが、そこについてお話しします。

「O:der」の同期のための追加実装

さっそくGoを用いたPOS連携に入っていきます。今回は特に、POSレジからのマスタを「O:der」に同期するところの部分に関して、追加実装した部分があるので、そこの事例を紹介させていただきます。

では、どういうところ追加実装したかです。もともとマスタ同期という機能は「O:der」にありました。どういう機能かというと、店舗ごと個別にマスタ同期をしていきます。同期のボタンをポチポチと押していくことによって、POSレジにあるマスタを「O:der」に対して同期をする機能になっています。

ただ、店舗数が100店舗、200店舗あったり、チェーン店になればなるほど店舗の数も大きくなっていくので、1個1個に対して同期のボタンをポチポチ押していくのが正直こうしんどいです。そのため、店舗一括でマスタを同期させる機能を作ろうということになりました。

今回は「店舗一括でマスタ同期する」部分にフォーカスを当ててお話しします。

マスタとはなにか

先ほどからマスタ、マスタと言っていますが、「マスタってなんですか?」となります。「O:der」にはカテゴリーとメニューという概念があります。

カテゴリーに対してメニューが紐づいているので、スライドでいうと、「冷菜」に対して「カルパッチョ」や「生ハム盛り合わせ」が紐づいています。

カテゴリーは店舗横断で行われているので、どの店舗でも同じカテゴリーが横断して存在します。メニューは例えば地域によって、東北地方のメニューだったり、店舗個別のメニューだったり、店舗ごとに異なる関係性があります。

このマスタを同期する、仕様はどんなかたちになるでしょうか。まず、マスタ同期のリクエストはリクエストごとに排他制御がされている必要があります。

リクエストが成功したら202のacceptedを返して、実行はgoroutineで行う。既に他のリクエストによって実行中であれば、409のconflictedを返すのが、まず仕様の1個目としてあります。

次は、先ほどマスタ、マスタと言っていた、このマスタ同期のカテゴリーとメニューを、それぞれ同期するところになります。

マスタ同期のためのフロー

では、ここからは実際にどのようにマスタ同期するかのフローを図解して見ていきます。まず、カテゴリーに対してメニューが紐づくかたちなので、順番としてはカテゴリー、メニューの順で同期する必要があります。

続いて、マスタ一括同期なので、複数店舗同時に実行する必要があります。直列でどんどん処理を走らせていくこともできますが、100店舗、200店舗と店舗が多くなると処理時間もどんどん長くなっていくところがあるので、そこは同時にやる必要があります。

というところで仕様をまとめると、成功するパターンは、「O:der」からリクエストが来たら202のacceptedを返して、その後にカテゴリー同期、メニュー同期という順番で同期が走っていきます。

一方、すでに実行中だった場合は、リクエストに対して409のconflictedをPOS連携サービスが返すかたちになっています。

ビジネスロジックをどうGoで実装していくか

では、ここからは、今の見たビジネスロジックをどうGoで実装していくかを見ていきます。

まず、マスタ同期の仕様としてあるカテゴリー同期、メニュー同期の順で実行する必要があるところです。

こちらに関しては、チャネルを用いてカテゴリー同期が終わり次第、メニュー同期の実行をしていくのが実装のかたちとしてあります。

ここからちょっとコードを見ていきます。スライドのようなかたちでチャネルを使っていきます。

まずcategorySyncとmenuSyncというところで、カテゴリー同期、メニュー同期があります。まず、カテゴリー同期が終わった後にdeferでチャネルを送信します。menuSync、メニュー同期のほうはチャネルを受信するのを待ち受けて、送信されたチャネルを受信したら、メニュー同期の処理を行う実装に落ち着きました。

もう1つ、複数店舗同時に同期する必要があります。こちらはその名のとおり、goroutineで並列実行を行っていきます。

実際にどういうコードになるかというと、店舗ごとにgoroutineをどんどん走らせていくというかたちです。

以上をまとめると、カテゴリー同期をまず走らせて、その後にメニュー同期を店舗ごとにgoroutineで走らせていくかたちを採ります。カテゴリー同期が終わり次第チャネルを送信するので、受信したメニュー同期がどんどん実行されていくかたちを取ります。

実装で見逃していたポイント

実装したところで、学びのセクションに入っていきます。実装できたので、レビューを貰いに行ったんですが、処理の中でこのアトミックではない部分が実は存在してました。この「アトミックではない」というところ、自分も初めて聞いたのでちょっとググるところから始まったんですが。

アトミック、不可分性といわれるものになっていて、いろいろ書いていますが、「リクエストは、他のリクエストが何をしているか観測ができない」ところが、「アトミックである」部分になっています。

実際にどこかというと、先ほど冒頭でお伝えした仕様の、すでに実行中の場合、409のconflictedを返すところです。ここが仕様を守れていませんでした。

実際どういうところかをフローで見ていきます。同期を始めてロックの確認をして、ロック中であれば409のconflictedを返して、ロック中でなければそのまま処理を進めていくかたちを採っていますが、よく見ると、このロックの確認と取得までに間が空いてしまっています。

これによって何が起こるかというと、複数リクエストが走った場合、リクエストAだとまずロックの確認をしますが、その場合はまだロックがかかっていないので、処理が進んでいきます。

リクエストAがロックを取得する前にリクエストBのほうでロックを確認されると、ここでもリクエストBがロックがかかっていないと判断されます。なので、本当はリクエストBが409を返さないといけないところですが、リクエストAとリクエストBが共に202を返してしまうので、排他制御がされていないということになりました。

ではロックの確認と取得を確認した直後に取得すればいいんじゃないかというところで、ロジックを変えられるとは思っています。しかし、ここでも確認と取得、つまりselectとupdateの間に入られる可能性は0ではないです。

こちらはすでに他のいろいろなコードでも利用されているロジックを使いました。stateのfromとtoがあると思いますが、そちらを利用することにしました。

これをすると、例えばロックを解放されている状態から取得する状態の遷移を指定できるので、「どこからどこまで」の状態を持てます。selectの部分でロックを確認した時にロックがかかっていなかったとして、updateをかけにいった時に、すでに他のものによってロックがかかっている場合は、「更新行がなし」として、affectedの部分が1ではなくなります。つまり他のプロセスで変更されたと判断できるようになります。

不可分性、アトミックな部分を担保したうえで、どんどん負荷試験などを行いましたが、同時並列数が多すぎると高負荷になる、こちらが観点として抜けてました。

これどういうことかというと、POSレジと「O:der」に対してそれぞれリクエスト、POSレジに対してはマスタの取得、「O:der」に対してはマスタの更新というリクエストを送りますが、自分がgoroutineで同時並列に流していたために、短期間にリクエストが集中することが起こりました。

そのため、リクエストを分散させる、いわゆるtimeSleepを使うことによってリクエストを分散させ、POSレジ、「O:der」それぞれに対して負荷を分散させることを実現しました。

学びとTIPS

最後まとめにいきます。まず実装をとおしての学びです。自分がGoを学ぶ中で、チャネルだったりgoroutineはけっこう知っていましたが、それをどうビジネスロジックに当てはめて実装するかはあまりイメージできなかった部分があったので、ここは実務経験をとおしてできた初の経験だったと思っています。

また、チャネルというところで、struct{}を用いることによってメモリが削減になるTIPSも1つ学びとしてありました。

ロックの確認・取得・開放ですが、ここはやはりアトミックな部分を意識して今後も行いたいなと思っています。ロックだけではなくDBの値、つまり、テーブルの値の奪い合いだったり、更新がどんどんいろいろなリクエストでされてしまって、値がおかしくなるところは気をつけていきたいと思っています。

最後に負荷分散です。自分がやってるPOS連携サービスだからこそ、連携する元と連携する先、それぞれに対して優しいサービスとなりたいところ。負荷が一気にかかりすぎないようにというところが、この実装をとおして学んだことになっています。

自分からは以上です。ご清聴ありがとうございました。