どうやって技術的負債の雪だるまを生み出し、それを返済してきたか - 5年半越しの設計論
恥の多い生涯を送って来ました。
システムを開発していると、本当に多くの恥が生まれます。たとえば、こんな恥です。
テーブルの名前を付けミスったりは日常茶飯事。私が付けた変な名前が、自社の営業どころか他社のユーザーにまで浸透してたりもする。例えば、唐突に商品マスタに出てくる「グルーピングタグ」というカラムとか。(まじで意味不明)
いま商品マスタと呼ばれているマスタの物理名が「kiosk_pricings」とか。日本語でおk。kiosk_pricings.grouping_tagってなんだよ。
「pricing」テーブルにはpriceカラムがあるが、全てのレコードで0になっていて、システムでは一切使っていないとか。(そのうち消したい)
システムで使われている"正解"はkiosk_pricings.priceでした〜。
親子関係を間違えた事もある。チケットと決済の親子関係を入れ替えたりもした。まじでこれ作ったやつバカじゃないのって感じするよね。私もそう思う。
私はいま、主に一つのシステムを開発しています。2017/4/30にコードを書きはじめて、もう5年半以上経ちました。そのシステムを継続開発するにあたって、実に沢山の恥、もとい技術的負債を生み出しては対処するという事を繰り返しました。そこで、懺悔のつもりでまとめて書き記しました。
つまり、この記事は 様々な失敗とその埋め合わせのリスト です。
恥ずかしい話ですが、致命的ではないだろう範囲でかなり具体的な事情も書きました。具体的に書かないと、正確に伝える事が難しいと思ったからです。特に、失敗談として、どんな事を考えて判断した事が結果的に誤って、どうすればよかったのか?という事まで繋がって書かれている資料はそんなに多くない気がしており、この"知見"を我々のチケットシステムに限らず広くシステム開発に活かすことができれば嬉しいなあ、というような思いでまとめています。
ちなみに、5年半でシステムはこんな風に育っていきました。
コードの量はほぼ直線的に増えています。まだしばらくはこのペースで増え続けるのだろうな、という気がしています。ちなみにシステム全体のテーブル数は190でした。
前提
今回の対象となるシステム/サービスは「電子チケットのシステム」です。このシステムの具体的な守備範囲については後述します。
また、このサービスのコードを書き始めた時から、私は開発責任者でした。
- 全体の1/3程度は実際に私が書いたコードになっています。
- 基本的にすべてのコードを確認しています。(明示的にレビュアーでないとしても)
- たまにザルチェックのことがあります。
- スケジュールを決める権限、メンバーを採用する権限、実装する機能を決める権限があります。
- システムの取扱高や総利用者数など、重要な数値を把握しています。
- 1日1回は確認しています。
設計において気をつけていたこと
実践できていたかどうかはともかくとして、以下のことに気をつけていました。
- とにかくミニマムの機能の実装までのスピードを早くする。検証を回していくため。
- 過剰な作り込みを避ける。
- 最悪のエラーが起こったとしても、後で埋め合わせができる・復元ができるという状態をできる限り保つ。データロストしない。
- 機能間の依存性を少なくする。できる限り機能を独立にする。
- できる限り汎用的な機能にする。ある要望Aに対応する場合、その要望Aをできる限り抽象化して対応できる仕組みを作る。
- その機能の対象となる利用者、という意味で汎用的にする。特定顧客に極力依存しない。
- その機能の対象となる業務範囲、という意味で汎用的にする。似たような業務をある程度捉えられるようにする。
- 人間と狭義のシステムの作業分担を明確にする。人間も含めてシステムを形成する。
- CRUDの観点での漏れが発生しないようにする。
- データをストアする対象は極力マネージドサービスにする。
- マネージドサービスのスケール上限が、サービス全体のスケール上限になるようにする。マネージドサービスの上限までにボトルネックになる箇所を作らない。
ビジネス/運用的に気をつけていたこと
- 実装した内容が既存のシステム利用を破壊しないようであれば、すぐにマージ・すぐにリリース
- 既存のシステム利用への影響は極力少なくする
- ただし、今の時点で利用していない機能などは積極的に削除する場合がある
- 営業要望はとにかく可能な限り取り入れる
- (立ち上げ時期は)とにかく少しでも早くサービスを売れる状態にする
- 会社として売上が立たないと、営業のコストが浮いてしまう
- サービスを売るために足りない機能をとにかく実装する
- (立ち上げ時期は)とにかく少しでも早くサービスを売れる状態にする
これらの注意点は、サービスを生存させるという意味ではワークしたと思います。しかしながら、これらの注意点だけでは当然ながら不足していて、1年後・2年後・それ以上といったスパンで炸裂する技術的負債がたくさん発生しました。この記事では、上記のような設計上の注意点ではカバーできなかった技術的負債を列挙していきます。
サービスの本質の一部を捉え損なうことによるモデリングの歪み
最も影響が大きかった負債は、これです。ただ、「サービスの本質の一部を捉え損なう」とは具体的にどれぐらいの事なのか。5年半で少しずつ分かってきた事と、それに伴い発生していた負債について記していきます。
機能を追加する時に、既存の機能をベースに考えてしまう
「電子招待券の受付システム」という概念
私がこのシステムを作り始める時に企画(当時の会社の社長)から言われたのは、「電子招待券の受付システムを作ってほしい」でした。知り合いの別の会社の社長に「電子招待券の簡単な受付システムみたいなのがあったら、俺が売ってくるよ」と言われたからです。
ほんまかいなと思いつつ、じゃあ「電子招待券の受付システム」をつくろう!となったのが2017/4/30で、それまで電子チケットに全く触れた事もない、何ならチケットを要するイベントにすらほとんど行かないド素人の私は、5/1中に最低限の受付機能だけを作りました。
テーブルの設計ですが、チケットのシステムなので、チケットというテーブルとチケットテンプレートという簡素なテーブルを作りました。電子チケットの考え方として、1URL=1チケットという考え方があり、URLとその他の情報を持つチケット1枚1枚をデータとして永続化するという前提がありましたが、受付可能な時間などの情報は種類が同じであれば同じになるはず という仮定の元で、1つの基本情報に属するチケット、という捉え方をしました。
この「1つの基本情報」の概念にどう名前をつけるべきか、しばらく悩んだ結果、「チケットを、一つの雛形から量産する」というイメージでチケットテンプレートという名前にすることにしました。
この構成はごく自然であると思いますが、この時点ですでに「一つのイベントで、複数のチケットテンプレートが必要になる可能性がある」という事は見えていました。そこで、チケットテンプレートの親にあたる概念として「プロジェクト」というものを作りました。
プロジェクトとはなんなのか...?これも私は相当悩んで、しかしMicrosoftの開発ツールなどを使うとよく意味の分からないプロジェクトとかソリューションとかそういう物ができるので、とりあえずプロジェクトと呼んでおくか、みたいな考えでプロジェクトという概念を作りました。
後で結局「公演」とか「イベント」という概念を導入することになるのですが、当時は「電子招待券の受付システムが対象とするものは、必ずしもイベントと呼ばれるような種類のものとは限らない」「まして公演などと呼べるものではない、例えば結婚式はイベントかもしれないが公演ではないし、会議や打ち合わせは広義のイベントではあるが違和感がある」みたいな事を考えて、そのような色の薄い、謎の単語としてのプロジェクトを選定しました。
Project, TicketTemplate, Ticket あと詳しくは触れませんがProjecStampというもう一つのテーブルが、DB設計(sqlalchemyの宣言的な書き方をしたクラス)の最初のコミットに含まれていました。
この日のコミットでは、最低限の機能として「チケットを管理画面で生成して、そのURLをメール等で手渡しする」というような使い方を想定して、とりあえず営業用の招待券受付デモができるようにして、一旦作業を終えました。
※管理ユーザー等の概念はまだ作成していなくて、本当にデモをする/イメージが違ってないか確認するためだけの状態
発券機能はチケットテンプレートの情報を参照すれば実現できる、という罠
電子招待券の発券について。当初は「招待券」というのは券を作ってから送るのではないかと思っていましたが、利用者が予約する、自分で発券するというパターンがあるという事がわかりました。狭義の招待券ではあまりないかもしれませんが、「まあ、言われてみれば」と感じたのを覚えています。
管理画面では、発券するときはチケットテンプレートのidを指定して、その子としてチケットを作るという、よくある親子のデータの作り方みたいな事を実践していました。
チケット利用者向けの画面でも、単にデータを作るだけであれば、親であるところのチケットテンプレートのidを参照できるようにさえすれば、紐付けた子供を作ることができます。そこで、管理画面のidとは異なる適当な文字列をチケットテンプレートのユニークキーとして、発券機能を実装しました。
この親のidを指定して子を登録するパターンは、RESTなどでも自然に出てくる実装パターンですね。
この時はユニークキーを追加する必要はありましたが、その他特にテーブルのレイアウトが変わることもありませんでした。
実は結論としては、この考え方が間違っていた/十分でなかったのですが、とりあえず先に進みます。
「電子招待券の受付システム」で電子招待券を販売するのか、「電子招待券の販売・受付システム」なのか
我々のビジネス的なやり方で至らない部分もあり、電子招待券を発券して受付するというだけのシステムではうまく儲ける事ができず、儲けるために機能をいくつか追加していきました。そのうちの一つが決済機能でした。利用者が発券をする際に有料でチケットを購入できるようにする ということです。
こうして、「電子招待券の受付システム」は「電子招待券を購入可能な受付システム」になることが決まりました。
電子招待券を売るということは、それを販売するための金額の情報をどこかに保持する必要があります。
一番最初に考えられるやり方は、チケットテンプレートに金額をもたせるということで、チケットテンプレートに金額という数値のカラムを一つ追加して、保持するようにしました。
また、利用者の決済に関する情報を保存する必要もあります。
決済に関する情報はチケットに保持する事もできますが、さすがに金額のように1つのカラムでは済みません。決済代行会社の情報と紐付けるためのトークンや注文ID、その他諸々の情報を追加する必要がありますが、一方で電子チケットのすべてが有料でもないです。そこで、まずチケットが存在して、決済は必要な場合に限り追加で付加するという考え方に基づき、チケットの子として決済を登録するようにしました。
これが、大きな間違いであるとも気づかずに…
図では1:nとしていますが、実際の利用場面では1:1でした。
この機能をはじめて実装したのは2017年10月頃、その後決済代行会社が変わって実装し直して実際に使ったのは2018年の春でした。インターンの人に実装してもらった事を覚えています。
今にして思えば、我々が作るべきは「電子招待券を購入可能な受付システム」ではなくて「電子招待券の販売・受付システム」でした。どういう事かというと、「受付システムというベースに購入可能な機能を足す」のではなくて、「電子招待券の販売と受付ができるシステム」として再構成すべきだったということです。
なぜ我々が判断を誤ったのかを考えてみます。今回の状況を少し抽象化すると、
- テーブルAがある
- テーブルAにカラムを追加したいが、その追加カラムは既存機能で使わない場合もあり、NULLになり得る
- 現時点での機能(発券)では、テーブルBが単独で存在するという事は考えられない
このような抽象的な条件において、テーブルA=チケット、テーブルB=決済とすれば、チケットの子として決済をつける、というのは自然なことでした。一点この仕組みで微妙なのは、決済が完了する前にチケットテーブルに先にチケットのデータができてしまう、という点でしたが、それについては以下のような理屈で目をつぶっていました。
目をつぶった理由
当時、チケットの発券をするとき、すでにステータスという概念が存在していて、「チケットテーブルにデータがあるが使えないチケット」というものが存在していました。例えば、電子招待券を受け取った時に、その招待券に自分で名前を入力する必要がある場合などは「チケットテーブルにはデータが存在しているが、しかし名前が未入力なので使えない」となるようなステータス管理が既に為されていたのです。
そこで、このステータスの概念を拡張して、決済が必要な場合はチケットを先に仮データとして登録して、決済が完了すると本登録されたデータとして利用できる、というような構成を取ることにしました。
この構成にすると、従来のチケットの一覧画面で決済を管理できるという実装上の工数を抑えられるというメリットもあり、この考えを自然に受け入れてしまったのです。「電子招待券を購入可能にする」という事が念頭にあったために、そんな考え方で染まっていったのでした。
しかし、「電子招待券の販売・受付システム」をゼロベースで作るとすれば、多くの人はそうは考えないでしょう。
つまり、販売予約ないし申込のデータがあって、そのステータスが支払完了になったらチケットを発券する、というような考え方になるはずです。
ただ、現状の発券機能を使うという前提で、仮に"正しい"考え方を実現しようとすると、例えば購入時に発券するチケットのチケットテンプレートと決済はどう紐付けするのか?もしチケットテンプレートを選ばせるのであれば、その直接的な子であるチケットが先にできるのが自然ではないのか?といった仕組み的な問題にぶつかり、実装工数も増えます。
以下のようなテーブル構成も一瞬検討しましたが、手詰まりで上述の構造になりました。
どうしても、既存の発券の仕組みの設計に引きずられてしまう訳ですね。
さきほど発券機能で「実は結論としては、この考え方が間違っていた/十分でなかったのですが」と書いた部分です。この仕組みの問題を最終的にどう解決したかは後述するものとして、また先に進みます。
技術的負債は、これから雪だるま式に育っていきます。
次々と出てくる課題 - 複数商品購入、当日/前売の金額変更、手数料/利用料、座席指定
決済機能をつけた頃から少しずつ導入が増えていきましたが、私はまだまだ機能的に十分ではないように感じていました。具体的に課題になったのは、
- 複数商品購入:チケットを購入する際に複数商品を同時購入可能にする
- 1枚だけ発券する場合、複数枚発券する場合
- 当日/前売の金額変更:同じチケットテンプレートで別金額で販売したい
- 手数料/利用料:チケットを購入する際に枚数に比例する手数料/利用料を本体価格と別表記する
- 座席指定:チケットに 2F南 A列18番 といった座席番号を対応・指定させる
でした。これらの機能はいずれも2018年中に対応しましたが、既に少し負債を抱えた設計の上に設計をするので一層の負債を抱えた状態になりました。
複数商品購入の実現、当日/前売の金額変更
複数の商品を購入させるときにどうするか。そもそも、複数購入した時に出すチケットは1枚なのか、複数枚なのか。おとな2人こども1人と書いたチケットを1枚出すべきなのか、おとな2枚こども1枚をチケットとして出すべきなのか。
最終的にどちらのパターンにも対応しているのですが、我々が当時取ったアプローチは、まずチケットの枚数を1枚にする方法でした。というのは、受付をする時に複数枚をうまく使えるのか?という事に疑念があり、まずはチケットは1枚出すだけにしていました。
ただ、そうすると1つのチケットにいくつかの商品を結びつける必要があります。
この商品の概念は、当日/前売の金額変更でも使うことを想定したり、他で使われていたitemという単語を避けるなどした事によって、最終的に「Pricing」、価格設定という名前になりました。
PricingはProjectの子として、チケットを購入する時に選択させるようにしました。
また、Paymentの子として販売明細、どのPricingをどれだけ購入したかを示すSellingというテーブルも作ることにしました。
これによって、1枚発券するパターンには対応できるようになりました。
ただ、この状態だと、PricingとTicketTemplateの紐付きが無いため、購入画面にアクセスした時に当日券の価格設定と前売り券の価格設定を出し分けする仕組みが存在しませんでした。(購入画面≒発券画面はチケットテンプレートに紐付いていた事を思い出してください。)
そこで、TicketTemplatePricingというテーブルを作り、ticket_template_idをもたせて、TicketTemplatePricingが存在する商品のみを購入画面に表示するように制御することにしました。
この仕組を実現すると、TicketTemplateのpriceカラムは完全にお役御免となりました。
ところで、チケットを2枚発券して別の人に1枚渡すなどの利用シーンでは、1枚にまとめて発券することにはデメリットがありました。また、概念的に購入した商品がすべて1枚のチケットにまとまるのは便利ですが、利用者の視点では若干わかりにくい可能性がありました。チケットというものが示す単位が不明瞭になるので、案件によっては来場受付の場面でわかりにくい事もあります。(チケットを利用する人には様々な人がいて、リテラシーも様々です。例えば1000人の人がいれば、当たり前の事ですがその中には「最もリテラシーが高い人」も「最もリテラシーが低い人」も存在します。アリーナクラスなら1万人、ドームクラスなら数万人以上です。)
そこで、複数枚発券にも対応したのですが、このとき発券するチケットテンプレートは複数の場合がありました。例えば、おとな2枚こども1枚で購入した場合などで、チケットのデザインが異なる場合には、チケットテンプレートとしても異なるようになっていました。(チケットテンプレートは雛形なので、デザインが異なればチケットテンプレートも異なるべきという整理でした。)
そこで、TicketTemplatePricingにother_ticket_template_idというカラムをもたせて、これに 「そのTicketTemplatePricingを購入すると実際に発券されるチケットテンプレート」 を指定できるようにしました。
もはや、何を言っているのかわからなくなってきましたね...元々はTicketTemplateの価格設定として生まれたPricingテーブルに、「どのTicketTemplateの発券時に販売するのか」という意味のTicketTemplatePricingという謎概念ができ、最終的にTicketTemplatePricingには販売元のticket_template_idとオプションで実際に発券するother_ticket_template_idというカラムが存在している...(何を言ってるんだろう)
雪だるまも結構大きくなってきましたね。安心してください、まだまだ大きくなります。
手数料/利用料の実現
これは、Pricingに手数料という区分の価格設定(商品)を登録できるようにして、手数料の場合はチケットの枚数の分だけ自動で手数料商品の販売明細を登録する、というような仕組みで実現しました。
ところで、既にテーブル構造はかなり意味不明になっていましたが、実はトランザクションデータ(Payment/Ticket/Selling)の具体的な内容はもっと混沌としていました。
複数枚のチケットを同時に購入した場合のPaymentとTicketはどういう構造になるべきなのか?ということが中々難しい問題だったのです。
- 手数料やチケットそのものと異なる商品の概念が発生したことにより、チケットテンプレートと紐付かない商品がある(=チケットにすべての情報がない)
- 決済するときに決済代行とやりとりする情報を保存するためのテーブルは必要
- チケットの決済ステータスの判定は、決済テーブルの保持しているステータス項目に依存している
色々と検討した結果、当時はPaymentに自己親子構造をもたせるという安易な方法を採用し、parent_payment_idというカラムを追加しました。
具体的には、複数枚の決済をすると次のようなデータができる事になっていました。
このデータ構造の問題は、なんと言ってもTicketをPaymentと紐付けて集計する際の集計のしにくさにあります。ただ金額を集計するぐらいであればPaymentの金額の和を取るかSellingの単価と数量を掛けて和をとるかすればよいのですが、チケットの枚数や所有者の情報などを集計しようとすると大変です。
中央のステータス=支払済のPaymentが中心概念であるにも関わらず、それぞれのTicketはPaymentの親になっていて、しかもTicket同士は同一決済で購入されたか否かという情報を持っていないという、壊滅的な集計のしにくさです。
当時のSQLには
coalesce(parent_payment_id, payment_data.id) as payment_id
とか
parent_payment_id is null or parent_payment_id = (やや複雑なクエリ)
といった呪文がしばしば出てきていました。(Paymentは実はPaymentDataという名前でしたね、そういえば。決済をEntity化したのではなくて、あくまでも「決済情報」という概念だったので。。。)
Ticketの所有者が誰かという情報は正規化されていてTicketの方にplayer_idという名前で格納されており、その意味でもPaymentを通じた集計は基本的にはTicketとの結合が前提となっていました。運用上もパフォーマンス上も、正規化しすぎるとしんどいという事を実感しました。
ちなみに、これまで全く説明していなかったのですが、一つの商品を購入したときにチケットを複数発券するサブチケットという概念もありました。このサブチケットの枚数も集計をしようとすると、もはや意味不明な事になっていました。
※あるチケットがサブチケットを持つかどうかはチケットテンプレートの単位で規定されている
これも、当初の設計をベースに追加していくという思想で設計した事によって引き起こされた悪夢でした。
この構造を選んだ時は、せいぜい金額が集計できればOKという気持ちだったのですが、チケットの保有者数とかサブチケットの枚数のような数字を間違いなく集計するためには、相当面倒なクエリを発行することになってしまったのでした。
座席指定の実現
座席を指定できるようにするには、当然ですが座席のデータを保存できるようにする必要があります。
しばらく検討を重ね、次のような構造で保持することにしました。
この構成の考え方としては、同じ施設を利用して何度か公演をするので、座席は施設が改修されない限りは変わらないものと思って登録して、座席予約の方を公演ごとに作るというような考え方です。チケットを発券するときに、座席予約とチケットを紐付けるようにしていました。
素人考えでは、このような構造で良いのではと思ってしまいましたが、当然この構造では破綻します。
というのは、補助席とか臨時シートみたいなものが都度発生するからです。極めつけは新型コロナで、新型コロナによってソーシャルディスタンスという概念が世間に浸透し、新型コロナの流行具合に応じて座席を市松模様にしたりペア連席の長方形市松(?)にしたりフルキャパで入れたりという怪現象が発生するようになりました。座席(Seat)には連席を確保するために隣の席という概念・情報を保持していたので、市松模様レイアウトにおいても連席をシステム的に確保しようとすると、このやり方では完全に破綻してしまいます。
施設ごとに座席が固定の想定で、SeatReservationはSeatのidとPerformanceのidとステータス、紐づくTicketのidを保持している程度の簡素なテーブルだったので、SeatReservationだけで公演毎の差を表現するのは不可能でした。
極端な例でいうと、全10公演の案件があって、開始する前は全ての公演でほぼ同じレイアウト想定だったのが、後半だけレイアウトが市松模様に変わる、といった事も発生するわけです。このとき、施設や座席のマスタを共通で使っていると、どうにもなりません。(これは、DRYを徹底してあらゆるものを共通化した結果、修正するとどこかでバグが出るようになってしまう状況と本質的に似ています。)
2018年にこの設計をしていましたが、2020年には次のように変更することにしました。
施設が消えて、代わりに座席レイアウトと座席テンプレートというものができ、座席は公演と直接繋がるようになりました。つまり、公演の数分座席を量産するという考え方に切り替えて、その時に座席を作る元として座席テンプレートを使う、というような考え方です。
この設計に落ち着いて、座席を図面から起こす回数を減らしつつ、座席テンプレートから座席を公演毎に量産して、必要なら公演単位での座席位置などの柔軟な調整もできる、という状態になりました。
主要な改修作業は、一人の担当者をつけて1ヶ月ぐらいで終わりましたが、その後もちょっとずつ構造を調整して最終的に座席の設計として落ち着いたのが上記の図の状態でした。
この設計そのものには今のところ致命的な問題はないのですが、「テンプレート」の概念がチケットと座席で異なるようになってしまったのも、一種の技術的負債です。
つまり、座席テンプレートには、例えば座席図面におけるx座標とy座標のような情報を保持していて、座席とは直接の親子関係はなくてコピーのようなものを登録するという概念になるので、チケットテンプレートとチケットの関係とは意味合いが大きく異なるのですが、そのような差のある概念を同じ言葉で表してしまいました。罪深い。
在庫管理
今更という感じもしますが、チケットの販売は在庫管理が前提となる場合があります。
当初はチケットテンプレート単位で在庫を管理していたのですが、おとなチケットとこどもチケットを販売するというあたりから、チケットテンプレート単位による在庫管理が破綻しました。というのは、座席数が決まっている会場の場合、おとなとこどもの合計が在庫なのであって、それぞれ個別の在庫にはあまり意味がないからです。
これは「電子招待券の受付システム」では中々考えにくいことでしたが、なんせこの課題を解決しないとチケットを販売することができません。
この在庫管理を実現するには...?もちろん、限られた工数で。
我々の当時の結論は、
- PricingとTicketTemplatePricingにそれぞれ在庫管理の機能をもたせる
- 券種をまたいだ共通在庫を実現するため、席種という種類のPricingをつくり、複数の券種の親にする
でした。つまり、基本となる考え方はSellingを作った分だけそれぞれの単位での在庫を減らし、いずれかで在庫切れになったら販売できないようにするという考え方で、チケットを買うときには「おとな」「こども」という券種を表す商品だけではなく、その親となる席種という商品も一緒に購入したことにする、ということです。
実は、さきほどの図に「席種」というよくわからない販売明細が出来ていたのですが、この「席種」というのは共通在庫を抑えるためのある意味で仮想的な商品でした。
こうして、「価格設定」であったPricing、および「チケットテンプレート単位の価格設定」という謎の概念TicketTemplatePricingには在庫管理機能が追加されました。どうしてこうなった
理論設計との乖離による深刻なパフォーマンス問題
ところで、チケットを購入した時に生じるデータ(行)を簡易表現した図をもう一度見てみます。
これが理論設計だったのですが、事実は小説より奇なり。 なんと、これは事実ではなく、現実はもっとつらい状態になっていました。
つまり...
どーん!なんと、販売明細は数量1のレコードが山程生成されていました。
集計をすると適切な数量になるのですが、レコード数は想定していたよりも沢山生じていました。
そうすると、当然ですが、当初想定していたよりも負荷が発生してしまいました。
実はこの処理は在庫の関係でロックが発生したりするものだったので、これは本当に深刻なパフォーマンス問題を生じました。
ちなみにこのような設計でも、リクエストが直列であればそこそこの量を捌けており、これでlocustによる負荷試験ヨシ!などと思っていたのですが、実際に多数のアクセスが発生するとリクエストは直列ではなく、負荷試験で試したときよりもランダム・同時にリクエストが発生しました。それによって、ロック待ちや酷い時はロック待ちのプロセスが絶妙に発生してデッドロックが起こるなどして、本当に大変な事になりました。例えば5枚同時に購入したりすると、想定の数十倍以上のCPU負荷がかかったりします。つらい。。。
結局一番まずかったのはどういうことか
これまで、設計当初は違和感がない/自然な設計であった事が機能追加を経て負債の雪だるまを生み出していった過程について説明しました。途中でも少し述べていますが、このような雪だるまが生まれたきっかけとなったのは
「受付システムというベースに購入可能な機能を足す」のではなくて、「電子招待券の販売と受付ができるシステム」として再構成すべきだった
ということで、私は自分が作っているサービスの本質を理解できていませんでした。
いくつかのポイントで反省点があって、まずは「電子招待券の受付システム」というスタート地点に引きずられすぎてしまったこと。サービスを開発するとき、まずはMVP(Minimum Viable Product)を作れと言われますが、サービスがMinimumより小さいとマネタイズができないという課題があります。そこで機能を追加する事になるのですが、機能を追加するときには「今のサービスに機能を追加していくという事ではなくて、ゼロベースで機能追加された後のサービスを作るとしたらどう整理するか」という事を考えるべきでした。少なくともサービスとしての理想像はゼロベースで作った場合であって、それをどう実現していくかという考え方をすべきであった、というのが大きな反省です。
次に、そのスタート地点の先の展開について、単にゼロベースで整理できなかったという事以上に何が価値になるのかを理解できていなかったこと。
私は当初、「電子招待券の受付システム」の先はCRMだと考えていて、2017年末頃には、こんなポエムをインターン向けに書いたりしていました。
## 招待管理の本質
招待の管理が何か?という本質を追求するとき、一つの考え方としては、
**招待する側と招待される側を、現状(紙)では成し得ない方法で繋ぐ**
という考え方があります。
例えば、紙では
- リアルタイムのメッセージング
- リモートでの来場状況の把握
- 利用後に会場を離れてからのアンケートの投稿
などのことはできません。
当時、ポイントカードやクーポンの構想が生まれる中で色々と考えてみて、
**顧客との繋がり方を管理する**というのが目指すべき一つの方向なのかな、
と思ったのでした。
このような事を管理するシステムを、業務システム用語(?)ではCRMと言って、
有名なソフトではSalesForceやZOHOなどがあります。
## as CRM
このような色々な議論を経て、色々な顧客に対して
**CRMツールとしての売り込みも実施**しています。
電子スタンプのサービスの割に、ダッシュボード画面に少しコダワリをもっているのは、
そのような背景があったりします。
CRM自体は、一般に顧客と店舗・企業とのつながりを管理するための枠組みなので、
実は電子スタンプを利用した一サービスというものではなくて、
ビジネスにおいて非常に普遍的な概念です。
これは間違ったことを書いている訳ではないのですが、今にして思えば、実際にお金を生むために直接必要だったのは決済機能で、単純に販売/予約と受付を繋げるという時点で既に価値がありました。
というのは、チケットという業界に目を向けると、
- 予約
- 決済
- 発券
- 来場受付
- 事後対応
これらの業務について、それぞれ一つのシステムを導入するといった事例も存在していて、CRMというよりもそもそも一連の業務をつなげて提供する という事に意味があったのでした。
私は素人だったので、そこに価値があるという認識が全くなく、むしろそんなのは出来て当たり前だろうぐらいに思っていました。2019年2月、我々のサービスは「チケットのトータルソリューション」という触れ込みで展示会に出展したのですが、その時にもこの感覚が全然ピンと来ていませんでした。
これは「売り方が全然分かっていない」という事でもあるのですが、それ以上に「自分たちのシステムの本質的な価値が理解できていない」という事です。このような感覚があれば、予約・決済・発券という分け方で分けてモデリングをしていたと思いますが、そのような考え方が全然できておらず、「受付システムというベースに購入可能な機能を足す」ぐらいの雑な考え方をしていました。
ひょっとすると、私みたいなパターンはあまりないのかもしれませんが、自分がどういう価値を提供しているのかという事の本質は簡単に掴みそこねる、という教訓が得られました。
どう負債を返済したか、そしてどう新しい負債を生んだか
まず、業務的な概念としての予約/発券と、チケットテンプレートの役割を分離することにして、予約や決済などは券売所(Kiosk) という新しい概念と紐付けることにしました。これは、現実でチケットを購入するのが券売所であるとするなら、チケットを購入するためのURLはバーチャルな券売所と思えるであろう、という発想です。
その上で、TicketTemplateとPricingの関係を剥がして、TicketTemplatePricing -> KioskPricingとしました。システムの仕組み的な意味では、ざっくり、TicketTemplateの一部をKioskとして独立させたという感じでした。
また、TicketとPaymentの親子関係も逆転させて、Ticketは購入完了するまでテーブルにデータが出来ないようにしました。TicketがTicketTemplateの子であったのと対称的に、PaymentはKiosk(≒PaymentTemplate)の子になるようにしました。
結果、次のような状況になりました。
この修正は、2020年6月頃に、エイヤで2週間ほどで対応しました。かなりの量の修正で、当時の改修では最も規模の大きなものになりましたが、この修正自体はかなりすんなりと終わり、様々な面でパフォーマンスの向上を実感できた事を覚えています。
ちなみに、この修正のタイミングと前後しますが、プロジェクトとチケットテンプレートの間にも階層を増やしました。実は、詳しく説明していなかったのですが、このプロジェクトとチケットテンプレートの間にはTopicという謎のテーブルを間に作っていた時代があったのですが、このTopicをEventという名前に変更して、EventがProjectとTicketTemplateの間の階層として鎮座するようになっていました。
新しく生まれた課題(商品の価格をKioskPricing毎に設定させる、販売画面でどうやって商品の日程を表現するか)
予約/決済/発券の概念を分離したことにより、いくつかの設計上の課題・パフォーマンスの課題は改善しましたが、一方で次の課題も発生しました。
一つの課題(要望)は、商品の価格をPricing単位ではなくてKioskPricing単位で指定したいということです。この対応では、KioskPricingにpriceカラムを追加して、Pricingのpriceカラムは使わないようにしました。これによって、Pricingはいよいよ価格設定ではなくなりました。現状では、商品種類とか、総在庫の管理単位とか、それぐらいの意味のデータになっています。。。
もう一つ、より難しい課題としては、「購入ページで複数の日程に別れた商品たちをどのように選ばせるか?」というものがありました。
例えば複数の日程がある公演の商品について、1/1 10:00〜、1/1 10:30〜、...、1/2 10:00〜、1/2 10:30〜、...というように沢山の日程の中で、商品をどう選択させるかということです。
この例の場合には、1/1、1/2、...という選択と、10:00〜、10:30〜、...という選択の2階層が良いと思われますが、一般には日付ではなく第n公演という表現であったり、1/1 昼の部という表現であったり、様々な表現があります。
この選択方式については、結論からいうと、単純に文字列をカンマ区切りで保存して、それを分割したものを都度適当に構造化して取り扱う、というようなやり方で決着しました。ただ、この概念をうまく表す概念がなく、「販売ページで商品を集約するタグ」という意味で雑に「グルーピングタグ」という名前をつけました。
これがKioskPricing.grouping_tagです。
これはもはや他の名前を思いつかなかったので、営業メンバーも含め、管理画面を使う全ての人にグルーピングタグと呼んでもらっています。造語を定着させざるを得なかったパターンです。
これも、きっとそのうちもっといい名前があるはず(...と思って2年経過しました)
その他のスケールに向けた頻出課題・技術的負債の解消
さて、これまで、私が経験した技術的負債が生じるメカニズムと、実際に取った対策をお伝えしました。
一番まずい技術的負債の発生パターンは、「サービスの本質の一部を捉え損なうことによるモデリングの歪み」というものでしたが、他にもいろいろな課題の"頻出パターン"がありました。以下、頻出パターンについて説明します。
今展開しているデータをコピー的に"沢山"作って、沢山の場所で同じことをしたい
これは、例えば
- 一つのチケットテンプレートで受付ができる→沢山のチケットテンプレートで複数日程を(手軽に)作りたい
- 一つのプロジェクトで、一つの店舗の受付ができる→複数のプロジェクトを(手軽に)作りたい
- 一つの座席レイアウト・座席で公演を表現できる→沢山の公演を(手軽に)作りたい
- 一つの券売所・グルーピングタグで商品を使って一つの日程の販売ができる→複数の商品を(手軽に)作りたい
といったようなパターンです。これは、ある意味でシステムの本質とも言えて、システムによって特定の作業が短縮されたら、それをコピーしたり繰り返したりというのは自然な事ですし、システムの価値というのはそういうところにあります。
従って、仮に今作っているプログラム・システム・サービスが成功したら、何らかのレイヤーでそれを繰り返す事が発生する と考えるのが自然です。実際、成立したモデルをいかに"沢山"に展開できるかという事がビジネス的にも肝です。
"育った"概念を分離分割したい
これは、例えば我々の場合には以下のようなケースがありました。
- TicketTemplateとKiosk(発券機能)の分離
- TicketTemplateとTicketDesign(電子チケットデザイン情報)の分離
- PaymentとEntry(申込)の分離
これらのうち下2つは特に説明をしていませんでしたが、TicketTemplateの抱える情報量が多すぎてデザイン関連を分離したり、決済から申込を分離したりという事がありました。
親子構造の中間に新しく階層をつくる
これは、単に階層構造を作ること自体が目的というよりは、上で述べた2つの事を実現するための手段の一つとして、親子の間に階層をつくりたいというケースもあります。
- ProjectとTicketTemplateの間にEvent
- TicketTemplateとAdditionalFieldの間にAdditionalScheme
- EventとKioskの間にAccessCondition
こちらも、本文に登場していない謎のテーブルそれぞれの詳しい説明は避けますが、こうしたテーブルを"間に"追加することがありました。
不適切な名前や、別の業務領域での名前の重複
モデリングの歪みの中に含まれるかもしれませんが、以下のような不適切な名前、あるいは重複がありました。
- ProjectとTicketTemplateの間にTopicという名前の謎テーブルがあった(Eventに改名)
- もはやPricingではない在庫管理や商品種類を表現している概念
- TicketTemplatePricingという謎の概念
- Kioskは券売機端末(キオスク端末)と名前重複する
- 「受付」が予約申込受付と来場受付で重複するのを忌避した結果の歪み
- 「くじ」を表す概念が3種類ある
- Lottery:受付したときにランダムで割当をする概念
- Raffle:予約申込時にシリアルナンバー等を入れて当選しないと申込に進めない概念
- Provision(もはやくじではない):事前登録してから抽選を行うタイプの、チケットシステムで最も一般的にくじとして扱われるべき概念(事前登録という概念のつもりでProvisionとなったが、正直苦しい)
これらは、規模が大きい場合には境界づけられたコンテキストという分け方をするのだと思いますが、一つのシステムの中では出来る限り用語を統一したいという事もあるので、非常に難しいなあと思います。
むすび
リアルな技術的負債の話をしました。いかがでしたか?
私が「技術的負債」ということを耳にするシチュエーションでは、それが負債として認知されて炸裂している頃には、最初にコードを書き始めた人・設計した人が離任している、というような状況が多いように感じました。その点、私はずっと中心で設計をして実際にコードも書いていて、ここに書いた事を含めてこのシステムに関わる全ての最終判断を行ってきたので、「なぜ当時そのような判断をしたか」という事を要件、営業観点、設計からコードレベルまで、だいたい思い出す事ができます。
たかだか5年半の事ではありますが、それでも様々な誤判断によって失敗を積み重ねてきました。それらの失敗のストーリーをこの解像度で語れる人間は少し珍しい気がしたので、現時点での私の経験をまとめました。私が2017年にこのサービスのコードを書き始めた時に、知っておけば効率が良かったであろう情報が詰まっています。
最後に、この記事を読んで、「なんだ、思ったよりレベルが低いなあ」「こんなレベルで設計を語るなんてちゃんちゃらおかしい」と思った方。ぜひチケット業界でシステムを(できれば一緒に)作ってほしいです。まさに山程の技術的負債を抱えていた(そしてその一部は認知していなかった)2019年当時において、我々のサービスは観点によっては15の類似サービスの中で最も見どころのあるサービス でした。もちろん今もそのポジションは変わっていないと思います。業界の地図を塗り替えられると思うので、ぜひやりましょう。
弊社採用サイト
チケットソリューション事業部をご確認ください(表記揺れでTICKET事業部となっている場合もあります)
Discussion
大変参考になる記録を共有いただいて、ありがとうござます!
事業ドメインへの理解が深まるにつれて、モデリングとの乖離に苦悩する姿が克明に浮びました…!
負債のメタファーのケーススタディーとして非常に学びになります。
ところで、 grouping tag という命名の困難についてですが、商品に名前を取られてしまってるという印象を受けました。そして pricing が商品種別ないし在庫保管単位の機能を持ってるとのことで、これは SKU に相当する様に見えました。
参考までに、 EC SaaS の Shopify では、 SKU のことを商品バリエーション (product variant) と呼び、それらをまとめた単位として商品 (product) と命名してたりします。一般に、商品ページ 1ページにまとめて表示される単位が product で、そこからドリルダウンして 1種別を選ぶのが SKU という理解でいました。
ref.
@wint
コメントありがとうございます!
また、参考になる意見をありがとうございます。この記事を書いた時に、なにか意見をいただけたりするといいなあ、という下心もあったりしたので、大変ありがたいです。
私の理解では、
Pricing -> Product、KioskPricing -> SKU(Product variant)に近い概念、と思っていました。
というのが、一般的なECの場合、例えば「トウカイテイオーTシャツ」みたいなものがProductで、これを選択するとProductからドリルダウンしてS、M、L、XLみたいなサイズをSKUで選ぶ、というような構成と思われます。
これについてチケット業界的な表現をすると、席種/券種という単純な2階層構造であればそう考えることもできて、例えば
Product -> 「1月29日 第1公演 S席」(または「1月29日 第1公演」)
SKU/Product variant -> 「S席おとな」「S席こども」(または「S席おとな」「S席こども」「A席おとな」「A席こども」)
というような考え方ができると思います。
ただ、この時点で一つ課題があって、「S席おとな」「S席こども」は一見SKUのように見えるのですが、実際には「S席」という概念がSKUになっていて、S席おとな/S席こどもは金額を表現する概念ではあるが単独では在庫管理単位を意味しない、という事です。在庫と値付けに"ねじれ"があって、値付けの方が細かいんですよね(だから元々ProductではなくてPricingという言葉を選んだという背景もありました)
これは、(1)Productに在庫をもたせるか、(2)Product variantの方に席種を入れてしまって、券種と同時に席種を購入するというビジネスロジックにするか、といった方法で解決できて、我々はこの事象の解決としては(2)を選んでいます。
(その意味で、KioskPricingについては、もうSKUとか在庫みたいな名前にしてしまうのもアリかなあという気がしています。ただ、既に管理ユーザーの人たち、少なくとも数百人ぐらいには「商品」という名前で説明してしまっていて、それが結構難しいところがあります。。。。。)
在庫管理で悩ましいのが、更に公演数を増やした時にどう考えるべきか、という事に課題があります。
具体的には、1月29日 第2公演、1月30日 第1公演、1月30日 第2公演、...などが増えた場合です。
我々が実際に登録しているデータでは、1つの施設の中の1つの展示群のチケットで SKUの単位では数万レコードを超えるものがあります。例えば、1日20コマぐらいあって、期間が60日ぐらいあって、チケットの種類が20種類あると20×60×20=24000SKUということです。
(このチケット20種類というのは、例えば大人、子供、障害者、優待A、優待B、グッズ付き大人、グッズ付き子供、グッズ付き障害者、....みたいなものによって構成されます)
このSKUのレコード数が増えるのは、(スパースな行列を扱うようなやり方をしない限りは)原理的に避けられない事だと思っていますが、一方でProductのレコード数まで増やしてしまうと、例えば商品名みたいな概念を数万レコードでバラバラに管理するのか?というような課題があって、更新忘れとか名前の整合性誤りとか、そういった事が容易に想像されました。それで、商品名みたいなものはSKUに含めたくない(何らかの少数のマスタを参照する構成にしたい)というのがあります。
あと、より複雑な条件として、この場合に「公演を跨って販売可能数の上限がある場合」というものがあって、
・各公演での在庫数はSKU/Product variant(KioskPricing)単位に従う
・特別なグッズ付きのため、公演全体での定量在庫がある
というようなケースがあります。
つまり、「入場券、グッズ付き入場券」という2種類のチケットが全ての公演分存在していて、グッズ付き入場券は全公演を通して1000枚しか売ってはいけない、みたいな場合です。
これは、公演をまたいだ在庫管理が必要で、我々の仕組みではProduct(Pricing)の方に在庫をもたせて管理するという事をしています。
さて、これを踏まえてのgrouping_tagですが、きちんとマスタ化するとしたら、それは公演とか、あるいは一種の販売単位みたいな概念にあたると考えています。本質的にはProductとProduct variantの階層が1つでは足りないという事なのだと思っていて、Productの上か、ProductとProduct variantの間か、Product variantの下か、どこかに階層を増やした方が自然なのだろうと思っています。現状の仕組みでいうと、ProductとProduct variantの間が一番良いのではないかと思ってはいます。
ただ、その仕組にした場合、上述のように24000種類のSKU単位がある場合に、人間が3つの階層を適切に管理できるのか?という課題があります。
そもそも、現在の状態で、2つの階層構造でも管理をするのがそこそこ大変になっていて、これを3つに増やした時に一般的なユーザーが整合性を維持できるのか...?というのはすごく気になっています。
基本的には、24000種類みたいな大量のSKUは20×60×20みたいな組み合わせで生成されるものなので、組み合わせを適切に管理できればよいと思うのですが、これを丁寧に管理しようとするとテーブルが複数個増えるような気がしていて、逆に単純な販売案件でそこまでデータを登録しなければならないのか、といった課題が生じます。
おそらく、管理画面および入出力方法、特にインポート処理をめちゃくちゃわかりやすくする前提で、内部のデータ構造のみgrouping_tagを間の階層としてきちんとマスタ化して、利用者にはそれを感じさせないみたいな状態が解なのだろうと思っていますが、チケットシステム全体では在庫管理以外に大きな課題があって、まだそこまでは検討ができていません。
(ちなみに、パフォーマンスに関しても課題があって、正規化した場合にデータ取得等を最適化できるのか?というのがあるのですが、たぶんこれは"ちゃんと考えれば"正規化した方が早いんじゃないかなという気がしています。マスタデータの登録は遅くなる可能性がありますが。)
おお…、種数が24000種類のオーダーで存在するんですか…!なるほど、これは難しいですね…。
物理的な席をSKUにするのがメンタルモデルとしてシンプルかと思いきや、大人・子供があったり、別の物理的なグッズとの“セット販売”があったりなど、全然一筋縄では行かないんですねぇ…。席より小さそうな単位で言いますと、たとえば子供料金は無理矢理「割引き」概念にマッピングした上で、表示を工夫するくらいはできるかも知れません。ただ、 Product ないし Product variant を跨ぐ制約は、対応物が無さそうですね。やはりフルスクラッチに至るのは必然のようですね。一端が見えた気がします。
改めて、モデリングの現場感が非常にエキサイティングな記事でした。これからも健闘をお祈りしてます!
大変興味深い記事をありがとうございます。「綺麗に負債を返済した」というお手本のような学びのないスライドでなく、失敗例を赤裸々に書いていらっしゃるのが好感を持てました。
(まとまった時間があるときにじっくり読みたい…)