「UXライティングの教科書」の読書会を実施しました

デザイナーのtorimizunoです。 デザイナーメンバーで、今現在のスキルマップと今後できるようになりたいことの共有会を実施したところ、全員に「ライティング」が入っていたので、勉強のために読書会を立ち上げました。

f:id:kanmu-tech:20210913161657p:plain

カンムでは日々勉強会やデータ会などが開催されており、この記事ではその取り組みのひとつとして、今回の読書会を紹介します。

対象の本: UXライティングの教科書 ユーザーの心をひきつけるマイクロコピーの書き方

www.amazon.co.jp

進め方

開催するにあたり、まず会の目的と進め方を定義して参加メンバーに伝えました。

背景

  • アプリなどのプロダクトの文言決めについて、チーム内で議論をする場面が多々ある
  • マイクロコピーというライティングの分野があり、ユーザビリティや顧客エンゲージメントを向上させる文言についてのノウハウが蓄積されている

目的

  • ユーザビリティやUXを向上させる文言やそれを決めるためのノウハウを学習し、文言決めをスムーズに行えるようになる、またはアドバイスできるようになる

メンバー

  • 必須
    • デザイナーメンバー
  • 任意
    • 希望者

せっかくならデザイナー以外でも興味ある人なら誰でも参加していただきたいな…と声をかけたところ、COO・マーケティング・採用・エンジニア・bizdev・CSメンバーも参加の挙手をしてくれて、10人での参加になりました!

f:id:kanmu-tech:20210913160445p:plain

実施方法

初回は読書会参加メンバーの顔合わせも兼ねて背景・目的・実施方法について共有するキックオフを実施しました。 それ以降は下記の内容で各章を実施していきます。

  • 毎週当番が章の要点をまとめて発表・議論する(60min…早く終わり次第解散)
    • 業務に役立ちそうな知見 / 要約
    • 現状のプロダクトとの比較 / プロダクトで実践
    • よく理解できなかった点

また、すべての章が終わったタイミングで、振り返りを実施しました。 自分自身がカンムで読書会を主催するのがはじめてだったのもあり、会自体どうだったのか参加メンバーからの声を吸い上げたかった&参加者で共有したかった、今後の読書会に活かせるナレッジを全員で得られるようにしたかった…のが目的です。

実施してみて

最初にボイス&トーンの章があり、これらがユーザーに与える影響の大きさからライティングの大事さの認識を揃えた後、具体的なライティングの勉強について入る…という本の構成がよかったです。

次に参加者でブランド・ユーザー・現状について改めて議論できる機会があり、新しいメンバーが知らない過去のブランドで考えていたことを知れたり、現時点、今後の話もディスカッションできました。

▼ブランドを体現する言葉について議論したもの f:id:kanmu-tech:20210913160723p:plain

毎週の章では、内容の共有の他、現状プロダクトでどうなっているかを見つめ直し、教科書を踏まえた上でどうあるべきかを全員がその場で考えてディスカッションするムーブが自然発生していったのがとてもよかったと感じました。

プロダクト開発チームの垣根を越えそれぞれの職種ごとの視点の意見交換ができていて、もっとこういう機会を普段のプロダクト開発でも増やしたいなぁ…とも感じました。

▼ディスカッションのメモ f:id:kanmu-tech:20210913161437p:plain

読書会以外の時間でも実施した意味を感じていて、参加者の方とのMTG中に「UXライディングの教科書で登場した〜」というような、読書会から索引された内容の意見が飛び交う瞬間があり、言葉を考える時の判断材料のひとつに取り込まれていっているのを感じています。

読書会を通して、実際にプロダクトの改善施策も何個か走り、ダイアログ・プッシュ文言・新規登録時などのライティング改善にも活かされていっています。

本の自体の感想としては、「教科書」と名がついているのもあってライティングを考慮時に考えるべき視点を体型的に学ぶことができ、エラーメッセージなど細かい点も何を意識して考えるべきかがまとまっているので、デザインを考える上で手元に置いていつでも参照したい本のひとつになりました。

振り返りでの参加メンバーのコメント

  • 文言を考える指針ができたのでよかった
  • 各テーマ毎に視点を絞ってアプリを確認できた
  • 「UXライティングのアレ」みたいな会話でコンテキストを圧縮できているような気がする
  • 本は例がWeb寄り、アプリという限られたスペースを有効活用する場合はどうなるももっと知りたい
  • フリーディスカッションの時間もっと欲しかった
  • デザイナーが学びたいこと=割と誰でも役にたちそう
  • アクセシビリティの視点を持ってなかったので面白かった
  • いろんな人のプロダクトの現状イケてないポイントが知れたのはよかった

今後

振り返りで読書会のやり方自体についても議論があり、その点については本によっていろんな読書会のやり方が考えられそうだなと思いました。

読書会のやり方に対するメンバーのコメント

  • ひとりで読むと大変そうな厚い本の負担が分散できた
  • 要約が大変、でも要約すると考えは整理される、自分は要約だからできた
  • 引用が多いと読書会の意味を考えるが、後でesa(社内wiki)で読めるエッセンスとして見れる観点もある
  • やり方のフォーマットがあれば楽かもしれない
  • 本に載ってない知識を引っ張り出せたのがよかった

今後はこれらのコメントを参考に、また別の分野でも、読書会や勉強会を実施していきたいです!

カンムでは学習と共有に一緒に取り組む仲間を募集しています。

team.kanmu.co.jp

カンム流『朝会』をやってみたら予想以上にウケが良かった件

はじめに

f:id:kanmu-tech:20210910182508p:plain

こんにちは、カンムでバンドルカードの機械学習部分を担当しているfkubotaです。
今回は、カンムで行っている雑談朝会について書こうと思います。
いきなりですが朝会や雑談会などを普段やっていますか?
コロナの影響でリモートになって「コミュニケーションが足りてない!朝会やるぞ!」という感じではじめた会社、チームは多いのではないでしょうか。
僕も前職含めコロナで失われたコミュニケーションを取り戻したいというモチベーションの朝会に参加していました。
ただこの朝会、とても運用難しいですよね。
勢いよくはじめたものの「この会意味あるの?」 と各人が自問してしまうような会は多くあるはずです。
この記事ではリモートワークで失われてしまった愛すべき雑談を取り戻したいという思いではじめた朝会運用について紹介したいと思います。
僕は朝会は大きく分けて二種類あると考えています。

  • タスクの共有
  • コミュニケーションを増やす

今回紹介する朝会はコミュニケーションに比重を全振りしているので仕事の生産性がどうのこうのという話は一切出てきませんのでご了承ください。(巡り巡って生産性があがる可能性は十分ありますが)

朝会の運用は難しい

f:id:kanmu-tech:20210910182658p:plain ここで一度、朝会の難かしさと解決したい問題点を洗い出します。

  • 朝会パターン1: 業務報告会&ついでに雑談
    • これは一番多いパターンではないでしょうか。
    • 昨日やったこと、今日やること、困ってることを各自報告するようなパターンです。
    • 雑談あればついでにやるような感じです。
    • このパターンは慣れてくると「キノウハ、ナニナニシマシター。キョウモオナジデース。イジョウデース。」のようにこなすだけの形骸化した朝会になりがちです。周りの人も本当に真面目に聞いているのか怪しいところです。
    • 形骸化した朝会は早めに時間がすぎることが好ましいため雑談は減る傾向にあります。よって雑談は自然と少なくなるでしょう。
  • 朝会パターン2: 雑談会(強制参加)
    • 集まって雑談しようの会です。
    • 毎日同じメンバーで話すので雑談はすぐに尽きます。
    • よく喋る人がたくさん喋って聞き専みたいな人が生まれるのも必然かもしれません。
    • それとそもそもなのですが、雑談があまり得意でない人がいるのも考慮したいところです。
  • 朝会パターン3: 雑談会(任意)
    • 最初は盛り上がりますが、徐々に人が来なくなります。
    • きっと「数人いるなら入ろう」の精神の人が多いはずなので牽制しあって結局誰も入らないパターンですね。
    • 静かになくなっていく悲しい会になりやすいと思います。

なんとなく想像がついたところで上記から戦わなければいけない問題点を洗い出します。

  • 形骸化させない
  • 毎回同じメンバーにさせない
  • 強制参加にしない
  • 参加者はできるだけ発言できるようにする

これともう一つ要素を追加します。コロナ禍に転職した関係構築に四苦八苦しているであろう入社したての人への配慮も入れておきます。

  • 入社したての人への配慮 をする

考えた朝会のカタチ

f:id:kanmu-tech:20210910182831p:plain

それでは、上記の問題を同時に解決する策を数ヶ月コツコツと考えた結果を紹介します。
僕の中では紆余曲折あったのですが、結論だけ述べます。
以下のような方法を取っています。(Slack運用を想定)

  • pj-asakai などの朝会専用チャンネルを作成
  • 朝会に参加したい人はここに入る
  • 活動は、火曜日と木曜日の朝に15分
  • google meet を使用し、できるだけカメラONにしている
  • 聞き専はNG
  • 参加メンバー は多くても6人ぐらい <--- これ大事
    • 固定メンバー
      • 直近3ヶ月以内入社組(3, 4人程度想定)
      • ホスト1人
    • ランダムメンバー
      • チャンネルに所属しているメンバーからランダムに2人を選ぶ(SlackAppで実行。後ほど紹介します。)

こんな感じです。ランダムに選ばれるメンバーがいることが大事なポイントです。
(具体的な人数は組織の大きさなどに応じて適宜変える必要はあります)

さきほどあげた問題点と照らし合わせます

  • 形骸化させない
    • 週に2回15分だけなのでちょっと物足りないぐらいで終わる。(無言の気まずい時間が最小限)
    • メンバーが毎回変わる
  • 毎回同じメンバーにさせない
    • ランダム枠を用意している
  • 強制参加にしない
    • ランダムに選ばれる人は、pj-asakaiチャンネル に所属している人だけ なのでチャンネルに参加した人は参加したい意思表示をしたことになります。
    • チャンネルに参加しない限りランダムで選ばれることはありません。
  • 参加者はできるだけ発言できるようにする
    • 少人数制かつ聞き専NGなので話しやすい
  • 入社したての人への配慮 をする
    • 直近3ヶ月以内に入社した人は固定メンバーとしている
    • 社内の人なら誰でもOKなので普段仕事で関わらない人とも雑談を楽しめる

こんな感じです。あげた問題にしっかり対処できています! :)

やってみてどうだったか

このアイデアをながいこと温めてカンムで開催される月に1度の TechDayで発表しました。
共感してくれる人が多く、社員39名(2021年9月時点)のうち19人が朝会チャンネルにさっそく入ってくれました。
(こういうイベントに飛びつく人が多いのもカンムの楽しいところ)

評判は結構いいです。僕自身も雑談たくさんできてウキウキです。
特に直近入社組は仕事で関わらない人と雑談できるということで喜んでいただけているようです。
開始してまだ2ヶ月弱ですが今の所毎回大盛りあがりです。
マニアックな話題が出て時間足りないよーって感じで惜しく終わるのも続いているポイントなのかもしれません。

  • 話題例
    • メガネの話
    • 引っ越しの話
    • 今年のベストバイ!の話
    • などなど

会後のslackでワイワイも楽しいですね。

f:id:kanmu-tech:20210913173041p:plain


f:id:kanmu-tech:20210913173108p:plain


f:id:kanmu-tech:20210913173152p:plain

紹介は以上です。
結構楽しいので雑談会を定期的に実行したい場合は参考にしてみてください!

おまけ(ランダムにメンバー選ぶSlackApp)

ランダムにメンバーを選ぶ時にいろいろやり方があると思うのですが、カンムでは Picker というSlack Appを使っています。 使い方は簡単で
/pick other 朝会だよ! を実行すれば、チャンネルに属している人の中から一人が選ばれてメンションされます。
(otherは任意のタスク的な意味なのであまり気にしないでください。)

このアプリはかなり融通がきく仕様になっていて、

  • 固定メンバーのA,B,Cさんはランダム候補から常に除く
  • Dさんを一度だけ除く
  • 2人選ぶ

などのオプションを付与できます。
/pick help を見れば簡単にわかるのでなにか困ったときにはヘルプを見てみてください。
最後にコマンド実行例画像を添えて終わりたいと思います。

/pick ot :asakai: -choose 2 (←2人選ぶ) コマンド実行例

f:id:kanmu-tech:20210910181802p:plain
picker

お決まりの

積極採用中です!!

kanmu.co.jp

決済システムの残高管理周りの DB 設計と戦略

エンジニアの佐野です。今日はカンムの決済システムでユーザの残高管理をどうやっているかについて書きます。

カンムの製品であるバンドルカードはプリペイド方式のカードです。ユーザによる入金、店舗での利用、運営事由の操作などによりユーザの残高が増減します。このような残高の管理について単純に考えると user_id と balance と updated_at あたりをもったテーブルを用意して balance と updated_at を更新していく方法があるかもしれません。しかしながらカンムでは残高を管理するテーブルを持たず、これらイベントの履歴のみで残高を管理しています。以下、本記事ではこれらユーザの残高が増減するイベントのことをトランザクションと呼びます。ここでは DB の Transaction Processing を意味しません。

本記事のポイントは

  • 残高を管理をするテーブルは作らず、トランザクション履歴のsumで都度残高を算出している
  • そのクエリは遅くなるのでは?とよく言われますがそんなことはない、雰囲気で判断するのではなく実行計画とベンチマークで判断しましょう
  • とはいいつつデータ量が増えたときの戦略を考案しておきましょう

です。想定読者は DB の論理設計を行う人間です。

  • 前提知識
  • トランザクション周りのDB設計
  • 残高算出のクエリとその実行計画
  • データ量が増えたときの戦略
  • まとめ

の順で話していきます。


1. 前提知識

業務用語や専門用語が出てくるため、まずは読んでもらうための前提知識をいくつか説明します。

  • トランザクションの種類
  • オーソリとクリアリング
  • Available Balance と Ledger Balance という2つの残高の概念

1.1. トランザクションの種類

冒頭で述べた通り、入金、決済などユーザの残高が動くイベントをここではトランザクションと呼びます。業務上、トランザクションは5つの種別に分けています。

  • 入金
    • ユーザがバンドルカードに入金するときに発生します。
    • 残高を加算します。
  • 手数料徴収
    • 手数料です。例えばバンドルカードに会員登録をするとECサイトなどで使えるバーチャルカードが手に入ります。実店舗で使いたい場合はリアルカードを申請していただく必要があります。現在リアルカード申請時にはいくらか手数料をいただいており、その申請時に発生します。
    • 残高を減算します。
  • 運営事由
    • 運営判断によりユーザの残高を増減させることがあります。例えば退会時、インセンティブの付与時など。
    • 残高を加算することもありますが減算することもあります。
  • オーソリ
    • 買い物をすると実店舗/ECサイトなどからカード番号や有効期限に加えて決済金額が飛んできます。それらの情報を使ってカンムのシステムは決済可能かどうかを判断します。Authorization / 仮売り上げ / 与信と呼ばれたりします。
    • 残高を減算します。返品されたときは加算することもあります。
  • クリアリング
    • オーソリで決済OKになったものについて、後日店舗から確定伝票が送信されます。これが届くとカンムのシステムは対応するオーソリを探して、その金額を「確定」とします。Clearing / 実売り上げと呼ばれたりします。
    • オーソリで減算または加算した残高をさらに減算または加算することがあります。どういうことだ?と感じると思うので次節オーソリとクリアリングで説明します。

1.2. オーソリとクリアリング

こちらの図は筆者が2年前に書いたものなのですが、この図の通りカード決済は2フェーズに分かれています。店舗で決済を行うとまずはオーソリが飛んできて残高が引かれますが、このオーソリの金額は仮の金額として扱われます(フェーズ1)。そして後日飛んでくるクリアリングの金額が実際の決済金額として扱われます(フェーズ2)。店舗での決済金額が仮で、後日改めて飛んでくるものが実際の金額とはどういうことだ?店舗で買ったときの値段=実売り上げではないのか?と思うかもしれません。その通り、ほとんどの場合において店舗での決済金額である仮売り上げと、実売り上げの金額はイコールになります。

しかしながらいくつかのケースにおいては仮売り上げと実売り上げで金額が異なる場合があります。卑近な例でいうと海外で決済して為替変動の影響を受けた場合になります。ある日、1ドル100円のものを購入したとします。このときオーソリは100円です。後日1ドル101円になったとします。その日にクリアリングが発生すると101円になります。結果として100円の買い物をしましたが101円を請求されます。また、筆者がドッグフーディングをした結果ですが、海外のレストランでのチップの支払いはオーソリに入っていなくて、クリアリングに上乗せされてくるようでした。なぜ2フェーズに分かれているのかはカード業界の歴史の話になりますのでここでは割愛しますが、このような仕組みがあるため、オーソリとクリアリングで金額が異なることがあり、後発のクリアリングを正として残高の補正が行われます。

1.3. Available Balance と Ledger Balance という2つの残高の概念

最後の蘊蓄です。ここまでは残高の増減が発生するイベント、トランザクションについて説明しました。それらのトランザクションに従って残高という数字を単純に増減すれば良いのですが、カード決済には Available Balance(AB), Ledger Balance(LB) という2つの残高の概念があり、トランザクションによってどちらを増減するかを判断しなければなりません。

長くなってしまうので詳細は省きますが、仮の金額であるオーソリで増減するのが Available Balance, 確定金額であるクリアリングで増減するのが Ledger Balance だと思ってください。

以下、各種トランザクションが発生したときの Available Balance と Ledger Balance の動きの例を示します。

Time Transaction Amount AB LB 説明
2021/6/27 10:00 入金 +1000 1000 1000 まずバンドルカードを使うために1000円の入金を行う
2021/6/27 11:00 アルカード発行手数料 -600 400 400 アルカード発行を申請、600円の申請手数料が引かれる
2021/6/28 09:00 オーソリ -130 270 400 130円の買い物を行う
2021/6/30 02:00 クリアリング -130 270 270 実売り上げが届き、130円として確定する

入金(+1000円) -> リアルカード発行手数料(-600円) -> 130円の買い物(-130円) -> クリアリング (-130円)というトランザクションの発生と残高の推移を表しています。 ポイントとしては先ほど書いた通りオーソリでは基本的に Available Balance のみが引かれます。クリアリングでは Ledger Balance が引かれますが Available Balance が引かれることもあります。このケースでは仮売り上げ=実売り上げなのでLBのみが引かれています。クリアリングで Available Balance が変化するときは前節で説明した通り、オーソリと金額が異なるときになります。

2. トランザクション周りのDB 設計

さて、DB設計です。これらのトランザクションを管理する実際のテーブルを見てみます。

関連するテーブルのみを抜き出すと、load, charge_fee, admin_fund, authorization, clearing が存在します。これらテーブルは5つの種別に分けたトランザクションにそれぞれ対応しています。load は入金、charge_fee は手数料、admin_fund は運営事由の残高増減、authorization は オーソリ、 clearing は クリアリングです。 card_id, card_holder_id というカラムが見えますが、card_id はカード情報を管理する card への外部キー、ユーザに似た概念であるカード所持者を管理する card_holder への外部キーになります。誰のどのカードにどのトランザクションが発生したかの関連を持たせていることを意味します。card_holder_id にはインデックスが貼られています。authorization テーブルには card_holder_id と response_code の複合インデックスが貼られています。

                                        Table "load"
     Column     |           Type           | Collation | Nullable |             Default
----------------+--------------------------+-----------+----------+----------------------------------
 id             | bigint                   |           | not null | nextval('load_id_seq'::regclass)
 card_id        | bigint                   |           | not null |
 card_holder_id | bigint                   |           | not null |
 amount         | numeric                  |           | not null |
 loaded_at      | timestamp with time zone |           | not null |

load は amout に入金金額が入ります。入金した金額はAB/LB双方に作用するので入金トランザクションの金額は amount というカラムのみで管理します。

                                        Table "charge_fee"
     Column     |           Type           | Collation | Nullable |                Default
----------------+--------------------------+-----------+----------+----------------------------------------
 id             | bigint                   |           | not null | nextval('charge_fee_id_seq'::regclass)
 card_id        | bigint                   |           | not null |
 card_holder_id | bigint                   |           | not null |
 amount         | numeric                  |           | not null |
 collected_at   | timestamp with time zone |           | not null |

charge_fee は amout に手数料金額が入ります。こちらもAB/LB双方に作用するのでこちらのトランザクションの金額も amount というカラムのみで管理します。

                                         Table "admin_fund"
      Column      |           Type           | Collation | Nullable |                Default
------------------+--------------------------+-----------+----------+----------------------------------------
 id               | bigint                   |           | not null | nextval('admin_fund_id_seq'::regclass)
 card_id          | bigint                   |           | not null |
 card_holder_id   | bigint                   |           | not null |
 available_amount | numeric                  |           | not null |
 ledger_amount    | numeric                  |           | not null |
 executed_at      | timestamp with time zone |           | not null |

admin_fund については柔軟性を持たせていて available_amount, ledger_amount 2つのカラムを用意して AB/LB にそれぞれ異なる金額を作用できるようにしてあります。

                                                   Table "authorization"
                Column                 |           Type           | Collation | Nullable |                   Default
---------------------------------------+--------------------------+-----------+----------+----------------------------------------------
 id                                    | bigint                   |           | not null | nextval('authorization_id_seq'::regclass)
 card_id                               | bigint                   |           | not null |
 card_holder_id                        | bigint                   |           | not null |
 transaction_amount                    | numeric                  |           | not null |
 response_code                         | text                     |           | not null |
 received_at                           | timestamp with time zone |           | not null |

オーソリは基本的には AB のみに作用するので金額は transaction_amount でのみ管理します。transaction_amount というカラム名はいろいろあってこうなっています(触れないでください)。オーソリは他のトランザクションと異なり、 OK/NG という概念が存在するため response_code というカラムをもっています。オーソリの失敗、成功を管理します。

                                                 Table "clearing"
              Column               |           Type           | Collation | Nullable |                      Default
-----------------------------------+--------------------------+-----------+----------+----------------------------------------------------
 id                                | bigint                   |           | not null | nextval('clearing_id_seq'::regclass)
 card_id                           | bigint                   |           | not null |
 card_holder_id                    | bigint                   |           | not null |
 available_amount                  | numeric                  |           | not null |
 ledger_amount                     | numeric                  |           | not null |
 created_at                        | timestamp with time zone |           | not null |

クリアリングは基本的にはLBに作用しますが、ABにも作用することがあるため available_amount, ledger_amount 2つのカラムを用意しています。

これらのテーブルには INSERT と SELECT しか行いません。各トランザクションが発生すると、トランザクション種別に応じたテーブルにひたすらデータを INSERT していきます。ユーザがアプリから見ることができる入金履歴や決済履歴、運営が管理画面から確認するユーザのトランザクション履歴はこれらのテーブルをすべて舐めて timestamp で order by しています。そして残高はこれらのテーブルの SUM を都度とることで表現します。

残高のみを管理するテーブルを用意しても良かったかもしれませんが、トランザクションの合算が残高の正解になるためわざわざ用意する必要がありません。正規化の観点からも計算可能な重複は DB からは省くべきなので教科書的には正しいです。またレビュー時に残高を更新しつつトランザクションをちゃんと INSERT しているか?などケアしなければならないポイントが増えます。これは経験則になってしまいますが、残高テーブルの数値とトランザクションの計算結果が合わない、などという事案が起きるかもしれません。

もちろん残高テーブルを単体で用意するメリットもあって、DB参照時にわかりやすい、トランザクションテーブルのSUMよりは確実に早いなどがあります。

このDBの設計を行ったときは全エンジニアで数日にわたって議論がなされました。議論の結果、トランザクションのSUMで良いとなったわけですがここではクエリのコストにフォーカスをあてて本当に遅いのか見てみます。

3. 残高算出のクエリとその実行計画

ではその残高計算のクエリはどうなっているでしょうか。実際のクエリは下記の通りです。

  SELECT COALESCE(SUM(available), 0) AS available_balance
        , COALESCE(SUM(ledger), 0) AS ledger_balance
    FROM (
      SELECT amount AS available, amount AS ledger
      FROM load
      WHERE card_holder_id = $1
    UNION ALL
      SELECT amount AS available, amount AS ledger
      FROM charge_fee
      WHERE card_holder_id = $1
    UNION ALL
      SELECT available_amount AS available, ledger_amount AS ledger
      FROM admin_fund
      WHERE card_holder_id = $1
    UNION ALL
      SELECT transaction_amount AS available, 0 AS ledger
      FROM authorization
      WHERE card_holder_id = $1 AND response_code = '00'
    UNION ALL
      SELECT available_amount AS available, ledger_amount AS ledger
      FROM clearing
      WHERE card_holder_id = $1
  ) AS _

すべてのトランザクションテーブルを card_holder_id でひっかけて、それぞれの金額を意味するカラムに別名をつけて UNION ALL して SUM をとります。

同じ商売をしているエンジニアの友人にもこのような設計とクエリについて話すと、遅そうだ、と言われます。ではここで実行計画を見てみます。本記事執筆時点で利用回数が多いユーザを card_holder_id に指定して、本番環境で実行計画をとった結果になります。

                                                                                     QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=1709.56..1709.57 rows=1 width=64) (actual time=13.112..13.113 rows=1 loops=1)
   ->  Append  (cost=0.43..1701.67 rows=526 width=64) (actual time=0.025..10.796 rows=7535 loops=1)
         ->  Index Scan using ix_load_card_holder_id on load  (cost=0.43..124.50 rows=48 width=10) (actual time=0.024..0.250 rows=315 loops=1)
               Index Cond: (card_holder_id = XXXX)
         ->  Index Scan using ix_charge_fee_card_holder_id on charge_fee  (cost=0.42..9.81 rows=2 width=10) (actual time=0.008..0.012 rows=6 loops=1)
               Index Cond: (card_holder_id = XXXX)
         ->  Index Scan using ix_admin_fund_card_holder_id on admin_fund  (cost=0.42..12.07 rows=2 width=7) (actual time=0.007..0.023 rows=19 loops=1)
               Index Cond: (card_holder_id = XXXX)
         ->  Subquery Scan on "*SELECT* 4"  (cost=0.57..726.99 rows=214 width=36) (actual time=0.020..4.686 rows=3623 loops=1)
               ->  Index Scan using ix_authorization_card_holder_id_response_code on authorization  (cost=0.57..724.31 rows=214 width=8) (actual time=0.018..3.038 rows=3623 loops=1)
                     Index Cond: ((card_holder_id = XXXX) AND (response_code = '00'::text))
         ->  Index Scan using ix_clearing_card_holder_id on clearing  (cost=0.56..822.55 rows=260 width=7) (actual time=0.017..3.024 rows=3572 loops=1)
               Index Cond: (card_holder_id = XXXX)
 Planning Time: 0.308 ms
 Execution Time: 13.175 ms
(15 rows)

意図したインデックスがちゃんと使われており、たいして遅くもありません。Row数は本記事執筆時点で各テーブル数千万〜数億です。またDBの総サイズは500GB程度、DBのマシンスペックは AWS RDS for PostgreSQL の db.m4.xlarge です。shared_buffers などには常識的な設定がされています。

トランザクションの総件数は億のオーダーですがインデックスが効いていてサクサク返ってきます。ただサイズが増えることによるコスト増は懸念しています。

4. データ量が増えたときの戦略

トランザクションの SUM で十分運用に耐えうることがわかりました。しかし今述べた通り、いずれは限界がきそうです。そのときどうするのかの戦略を練っておきます。将来的にはトランザクションのスナップショットをとる方式を採用するつもりでいます。他にはDB分割やスケールアップもあります。

4.1. トランザクションのスナップショットとは

先ほどのクエリでインデックスは効いていますが、ユーザが1年2年3年...と使うとフェッチする件数が増えます。データ転送量増加、SUMの計算コスト、単純なDB容量増加によるディスクアクセスなどによりパフォーマンスは徐々に悪化していきます。

スナップショット方式とは、次のようなテーブルを用意してトランザクションのある時点での残高の状態を記録し、この balance_snapthot テーブルに記録された残高と、それの created_at 以降のトランザクションを取得するようにしてフェッチする件数を減らす方式です。残高はこれらの結果を足し合わせて算出されます。

                                         Table "balance_snapshot"
      Column       |           Type           | Collation | Nullable |                   Default   
-------------------+--------------------------+-----------+----------+----------------------------------------------
 id                | bigint                   |           | not null | nextval('balance_snapshot_id_seq'::regclass)
 card_holder_id    | bigint                   |           | not null |
 available_balance | numeric                  |           | not null |
 ledger_balance    | numeric                  |           | not null |
 created_at        | timestamp with time zone |           | not null |

残高計算クエリも少し変更します。 WHERE 句にそれぞれのトランザクションテーブルのタイムスタンプを追加します。 card_holder_id との複合インデックスも貼ります。

  SELECT COALESCE(SUM(available), 0) AS available_balance
        , COALESCE(SUM(ledger), 0) AS ledger_balance
    FROM (
      SELECT amount AS available, amount AS ledger
      FROM load
      WHERE card_holder_id = $1 AND loaded_at > $2
    UNION ALL
      SELECT amount AS available, amount AS ledger
      FROM charge_fee
      WHERE card_holder_id = $1 AND collected_at > $2
    UNION ALL
      SELECT available_amount AS available, ledger_amount AS ledger
      FROM admin_fund
      WHERE card_holder_id = $1 AND executed_at > $2
    UNION ALL
      SELECT transaction_amount AS available, 0 AS ledger
      FROM authorization
      WHERE card_holder_id = $1 AND response_code = '00' AND received_at > $2
    UNION ALL
      SELECT available_amount AS available, ledger_amount AS ledger
      FROM clearing
      WHERE card_holder_id = $1 AND created_at > $2
  ) AS _

スナップショットテーブルとトランザクションテーブルを使った残高算出の擬似コードは次のようになります。

snapshot, found = get_latest_snapshot(card_holder_id)
if found {
  available_balance, ledger_balance = get_balance(card_holder_id, snapshot.created_at)
  return available_balance + snapshot.available_balance, ledger_balance + snapshot.ledger_balance
} else {
  available_balance, ledger_balance = get_balance(card_holder_id, '1970-01-01 00:00:00')
  return available_balance, ledger_balance
}

残高計算を行う箇所にてクエリの本数は1本増えますが、 balance_snapshot テーブルへのクエリは単純SELECTになるためたいしたコストはないと見込まれます。

4.2. スナップショット方式を採用した場合のベンチマーク

ちゃんとベンチマークを取って検証してみます。以下、ベンチマークテストの条件になります。

  • pgbench を使う
  • クエリは残高計算のクエリを3分間、where 句の card_holder_id をランダムに変更して実施
  • それぞれのシナリオ実施前にRDS 再起動する
  • RDS再起動後に一回 pgbench を流すがその結果は無視して、続く5回のpgbench試行をテスト結果とする
  • 実施前に統計情報はリセットする
  • RDS は本番と同スペックとして本番と同程度のデータを入れておく

pgbench は負荷クライアント用のインスタンスを建てて下記のコマンドで実行します。4並列(-c 4)で3分(-T 180)とします。

pgbench -h <host> -p <port> -U <username> -f balance.bench -r -c 4 -T 180

balance.bench は負荷を与えるシナリオが書かれたファイルで本試験では次のようにします。 \set ch random(1, 10000000) は、 ch という変数に 1から10000000のランダム値を与えるコマンドで、残高計算クエリの WHERE 句を WHERE card_holder_id = :ch としておくことで ch が毎回ランダムに選択されます。

BEGIN;
\set ch random(1, 10000000)

<残高計算クエリ> 
END;

統計情報もベンチの結果として参考にします。下記クエリでキャッシュヒット率を、

SELECT relname
       , round(heap_blks_hit*100/(heap_blks_hit+heap_blks_read), 2) AS heap_cache_hit_ratio
       , round(idx_blks_hit*100/(idx_blks_hit+idx_blks_read), 2) AS idx_cache_hit_ratio
FROM pg_statio_user_tables
WHERE schemaname = '<schema_name>'
  AND heap_blks_read > 0
ORDER BY heap_cache_hit_ratio,
         relname;

下記クエリでインデックスのキャッシュヒット率をベンチマーク実施後に取得します。

SELECT relname
       , indexrelname
       , round(idx_blks_hit*100/(idx_blks_hit+idx_blks_read), 2) AS idx_cache_hit_ratio
FROM pg_statio_user_indexes
WHERE schemaname = '<schema_name>'
  AND idx_blks_read > 0
ORDER BY idx_cache_hit_ratio,
         relname;

4.3. ベンチマーク結果

4.3.1. スナップショット方式適用前(現在の状態)
  • pgbench
n tps(3min) avg (msec)
1 418 6.396
2 422 6.306
3 422 6.293
4 427 6.176
5 437 5.944
  • キャッシュヒット率
        relname         | heap_cache_hit_ratio | idx_cache_hit_ratio
------------------------+----------------------+---------------------
 authorization          |                 8.00 |               93.00
 clearing               |                10.00 |               94.00
 admin_fund             |                30.00 |               99.00
 charge_fee             |                30.00 |               99.00
 load                   |                31.00 |               94.00
  • インデックスのキャッシュヒット率
        relname         |               indexrelname                        | idx_cache_hit_ratio
------------------------+---------------------------------------------------+---------------------
 authorization          | ix_authorization_card_holder_id_response_code     |    93.00
 load                   | ix_load_card_holder_id                            |    94.00
 clearing               | ix_clearing_card_holder_id                        |    94.00
 admin_fund             | ix_admin_fund_card_holder_id                      |    99.00
 charge_fee             | ix_charge_fee_card_holder_id                      |    99.00
  • スロークエリ数

12 (1秒以上のクエリ発生数)

4.3.2. スナップショット方式適用後
  • pgbench
n tps(3min) avg (msec)
1 717 2.406
2 734 2.287
3 743 2.208
4 750 2.163
5 752 2.142
  • キャッシュヒット率
        relname         | heap_cache_hit_ratio | idx_cache_hit_ratio
------------------------+----------------------+---------------------
 authorization          |                54.00 |               96.00
 clearing               |                65.00 |               97.00
 charge_fee             |                92.00 |               99.00
 load                   |                94.00 |               98.00
  • インデックスのキャッシュヒット率
        relname         |                         indexrelname                         | idx_cache_hit_ratio
------------------------+--------------------------------------------------------------+---------------------
 authorization          | ix_authorization_card_holder_id_response_code_received_at    |               96.00
 clearing               | ix_clearing_card_holder_id_created_at                        |               97.00
 load                   | ix_load_card_holder_id_loaded_at                             |               98.00
 admin_fund             | ix_admin_fund_card_holder_id_executed_at                     |               99.00
 charge_fee             | ix_charge_fee_card_holder_id_collected_at                    |               99.00
  • スロークエリ数

6 (1秒以上のクエリ発生数)

4.3. ベンチマーク考察

スナップショット方式を採用しつつインデックスを適切に貼ることでパフォーマンス、キャッシュヒット率とも改善が得られました。今後パフォーマンスが悪化するようであれば解の一つとなりえることを数値で示すことができたと言えます。ちなみに PostgreSQL の shared_buffers などのパラメータを変更したベンチも走らせましたが、このケースにおいては shared_buffers の変更は特に効果が得られず、 RDS のデフォルト値である物理メモリの 25% 程度(PostgreSQL 公式の推奨値でもある)がもっともパフォーマンスが良かったです。

現在のキャッシュヒット率が思ったより低いのでさっさとスナップショット方式にしてしまうのがいいかもしれません。今のところ致命的なユーザ影響は出ていませんが...。スケールアップもそろそろしてもいいかもしれません。

5. まとめ

  • 残高を履歴のみで表現する方式をとっているがクエリはサクサク返ってきます
  • クエリのコストは雰囲気で判断せずにオプティマイザに聞きましょう
  • DB運用の戦略を練っておきましょう
  • 戦略の判断も雰囲気で行うのではなくベンチマークをとるなど数字で判断しましょう

6. 最後に

仮に本記事が読まれた場合、同意が得られるかもしれませんし反論があるかもしれません。みなさんはどう考えますか?カンムではそういう考え方もあるな、ではこういう考え方はどうか?と議論できる人が合っている気がします。

会社のブログに記事を書くと言うことは最後にこれを書かなければなりません。採用しています↓

kanmu.co.jp

おわり

時点tにおける連続で結果xが出た回数(結果yが出たら回数はリセットされる)を出すSQLを書いた

カンムでCOOをしています、achikuです。

ニッチすぎて誰に話しても「?」となるが、とにかく理解できて嬉しかったSQLの話をする。

話しを簡単にする為にコイントスを用いた例題で説明する。

問題

複数回のコイントスの結果(試行ID、裏が出たか表が出たか、トスした時間)を記録したデータがある。このデータを用いて試行回数t回目において何回連続で表が出たかを出力したい。なお、一度裏が出たら連続で表が出た回数は0にリセットされる。

問題を表で表現する

最終形からイメージすると、大体以下のような事がしたい。試行回数t回目において連続でhead=trueである回数を出す。とにかく時系列に並べた際に時点tにおける連続表が出た回数が欲しい。

  • consecutive_head : 連続で表が出た回数

f:id:kanmu-tech:20210622213228p:plain
取得したい集合とコメント

解く為の考え方

まず完成形の集合を眺める。連続で表が出た回数は裏が出たらリセットされる。つまり、「裏が出る」というレコードでパーティションを作れれば良さそうな気がする。裏が出た累積回数と定義するとちょうど良さそう。ということで、Window関数を利用して良い感じにリセット出来ないかというところに行き着く。図で表すと以下のようなイメージ。

  • cumulative_tail : 累積裏が出た回数

f:id:kanmu-tech:20210622213316p:plain
青のラインがパーティション

小さく作って試す

CTEで小さく作って試すとアイディアが正しいのかどうかを手を動かしながら試しながら確認していけるのでとても良い。複雑なものを小さく分けて結果の正しさを確認していく行いはプログラムを書く際にとても役に立つ。以下はPostgreSQLで書いたがBigQueryでも少し修正すればいけるはず。

with

coin_toss(id, head, tossed_at) as (
  values
    (1, FALSE, '2021/06/22 10:00:00'::timestamp)
    , (2, FALSE, '2021/06/22 10:01:00'::timestamp)
    , (3, TRUE, '2021/06/22 10:02:00'::timestamp)
    , (4, FALSE, '2021/06/22 10:03:00'::timestamp)
    , (5, TRUE, '2021/06/22 10:04:00'::timestamp)
    , (6, TRUE, '2021/06/22 10:05:00'::timestamp)
    , (7, TRUE, '2021/06/22 10:06:00'::timestamp)
    , (8, FALSE, '2021/06/22 10:07:00'::timestamp)
    , (9, FALSE, '2021/06/22 10:08:00'::timestamp)
    , (10, TRUE, '2021/06/22 10:09:00'::timestamp)
    , (11, TRUE, '2021/06/22 10:10:00'::timestamp)
)

select
  c.id
  , c.head
  , c.tossed_at
  , sum(case when c.head = false then 1 else 0 end) over (order by c.id) as cumulative_tail
from coin_toss as c

これで上に乗せた図の cumulative_tail が出せる事は分かった。後は cumulative_tailパーティションを切って表が出た回数を試行順に累積していけば良い。

回答

with

coin_toss(id, head, tossed_at) as (
  values
    (1, FALSE, '2021/06/22 10:00:00'::timestamp)
    , (2, FALSE, '2021/06/22 10:01:00'::timestamp)
    , (3, TRUE, '2021/06/22 10:02:00'::timestamp)
    , (4, FALSE, '2021/06/22 10:03:00'::timestamp)
    , (5, TRUE, '2021/06/22 10:04:00'::timestamp)
    , (6, TRUE, '2021/06/22 10:05:00'::timestamp)
    , (7, TRUE, '2021/06/22 10:06:00'::timestamp)
    , (8, FALSE, '2021/06/22 10:07:00'::timestamp)
    , (9, FALSE, '2021/06/22 10:08:00'::timestamp)
    , (10, TRUE, '2021/06/22 10:09:00'::timestamp)
    , (11, TRUE, '2021/06/22 10:10:00'::timestamp)
)

, coin_toss_with_cumulative_tail as (
  select
    c.id
    , c.head
    , c.tossed_at
    , sum(case when c.head = false then 1 else 0 end) over (order by c.id) as cumulative_tail
  from coin_toss as c
)

select
  t.id
  , t.head
  , t.tossed_at
  , t.cumulative_tail
  , sum(case when t.head = true then 1 else 0 end) over (partition by t.cumulative_tail order by t.id) as consecutive_head
from coin_toss_with_cumulative_tail as t

どういう場合に使うのか

頻繁にツボから紅白の玉を取り出すことがある人達は便利に利用出来るSQLになっていると思う。それ以外にも例えばゲームを連続で何回成功したらその後n回の継続率に影響があるのか、連続何日サイトを訪れることでその後n日継続率に影響があるのか、等を調べる際にも使えるかもしれないが、ニッチな集計要件なので是非この機会にこのSQLを起点に分析を考えてみてみるのはいかがでしょう。

採用してます

「現場にある迫真の具体性を明らかにし、現状の課題を言語化/定量化し、チームの課題認識を揃える」という職を募集しています。データアナリストやマーケターと呼ばれることが多い職種なのですが、人によって定義がブレるので「現場にある迫真の具体性を明らかにし、現状の課題を言語化/定量化し、チームの課題認識を揃える」職としか言いようがないなと最近感じております。そのためにはそれぞれの専門領域はあれでも、定性調査だろうと、定量調査だろうと、効果検証だろうと、SQLを書くだろうと、Notebookを書くだろうと、なんでもやるチームです。以下を読むとやっていることがもう少し具体的になるかもしれません。

「あぁぁこのクエリ確かに面白いな!」と思ったら、achiku までお声掛けください!一緒にニッチな集計要件の話で盛り上がりましょう。

カンムにおけるインフラの考え方をまとめた「インフラマニフェスト」のご紹介

カンムでバンドルカードのバックエンドやインフラを担当している summerwind です。

バンドルカードではスマホ上で Visa のプリペイドカードを発行して決済に使える機能を提供しており、クレジットカード情報を扱っていることから、インフラの観点では高いセキュリティを維持することが重要になっています。バンドルカードのシステムは API や国際カードブランドと接続している決済システムなどの複数のコンポーネントで構成されていますが、システムが構築された時期によって構成や設定の方針などが異なるため、より高いセキュリティを達成するためにシステム構成の変更や整理、設定の見直しを日々進めています。

構成や設定の見直しを進めていく中で、全体的な方針や目指している姿を言語化しておいた方が周囲のエンジニアにも理解が得られやすいのではないかと感じたため、インフラに対する考え方や方針を言語化した「インフラマニフェスト」を定義することとしました。この記事では社内に公開しているマニフェストの一部を抜粋して紹介したいと思います。なお、日常的に AWSGCP を活用されているエンジニアの方には当然の内容だと思われるかもしれませんが、今回は「あえて言語化する」ことを目的としているので、その点はご了承ください。


このインフラマニフェストではカンムのインフラを「こうしていくんや!」という方針的なものを言語化しています。 これは2021年のカンムのインフラの状況に基づいて定義されており、今後の改善などにより将来的には大きく方針が変更される可能性があります。

対象

このマニフェストが対象とする「インフラ」には以下が含まれます。

ワークロードとストレージを分離する

アプリケーションやバッチなどのワークロードとそのデータを保存するストレージは明確にリソースを分離し、ワークロードとデータが同居しない構成や設計を採用します。

例えば、Redash を動かすための EC2 インスタンスがあり、そこで Redash、Redis、PostgreSQL を実行していたとします。これらは以下のようにリソースを分離できます。

  • Redash: ECS コンテナ (ワークロード)
  • Redis: ElastiCache (ストレージ)
  • PostgreSQL: RDS (ストレージ)

このように分離するのは、ワークロードは一般的に高速に動作しスケーラブルであることが求められ、また比較的短命であることに対し、ストレージは安定的に稼働し続けることが求められるなど、それぞれの要件が大きく異なるためです。複数の要件を達成しようとすると、どうしてもトレードオフが発生してしまい全ての要件を達成するのが困難になります。ワークロードとストレージを明確に分離すればそれぞれの役割における要件が達成しやすくなります。

なお、ストレージは一般的に運用負荷が高くなりがちなので、積極的にマネージドサービスを活用していきます。

ペットではなく家畜へ

ワークロードを実行するリソースは、ペットのように長時間稼働させて可愛がるのではなく、家畜のように使い捨てます。常に「私が死んでも代わりはいるもの」と言えるよう、デプロイのたびにリソースが入れ替わり続けるような状況を目指します。

これは、リソースを長時間稼働させればさせるほど、設定ドリフトが発生して設定や構成の変更がしにくくなったり、脆弱性の発見によりセキュリティが低下したりすることが背景にあります。長時間稼働により、設定ドリフトやセキュリティの低下に対応するための運用負荷も同時に高まっていきます。もしリソースを頻繁に使い捨てることが前提となっていれば、設定ドリフトは発生せず、最新のセキュリティを組み込むことも可能になり、アプリケーションやシステムの構成変更もしやすくなります。

ペットと家畜の話は以下の文書も参考してください。

メタデータで管理する

ワークロードやストレージなどの全てのリソースは名前のような一意の識別子に加え、メタデータを付与して管理します。

これは、インフラの運用ではリソースを様々な軸で集約して扱うシーンが多いためです。例えば、以下のようなシーンでは名前のような識別子だけでリソースを管理するには限界があります。集約したい軸ごとにメタデータを付与し、その値ごとに集約できると運用上の様々な課題を効率化できます。

  • サービス A とサービス B の API インスタンスのメトリクスだけを集約して見たい
  • 本番環境で稼働中の全ての RDS インスタンスのエラーログだけを集約して見たい

リソースには次のようなメタデータを必ず付与し、項目単体あるいは複数の項目を組み合わせてリソースの管理を可能にします。またこのようなメタデータを付与できるサービスやツールなどを積極的に採用することとします。

メタデータ 概要
環境 リソースがどの環境用 (本番環境、ステージング環境など) のものなのかを示す
サービス どのサービスあるいはプロダクトに属しているかを示す
役割 サービスの中での役割を示す (API、管理ツールなど)

データにより宣言的に管理する

全てのリソースの設定や状態はデータファイルに構造化データとして定義し、専用のツールを使用してその定義内容を実際のリソースと一致させることで管理します。構造化データには JSONYAML、HCL2 形式のいずれかを採用し、それらの形式でリソースを宣言的できるツールを積極的に採用します。

これまで一般的なインフラのリソースの多くは手順書などの「命令的」な定義により管理されてきましたが、これは順序に依存する仕組みであり、手順の変更などによって設定を再現できなくなったりする問題があります。古いドキュメントの手順に従って作業を実施したら環境を再現できなかったという経験がある方は多いのではないでしょうか。

この問題を解決するために、手順ではなく以下のようなデータファイルにリソースのあるべき状態を定義し、その定義に基づいてリソースの状態を一致させるツールを積極的に採用します。この例では ECS クラスタprdメタデータ付与された状態である、ということを Terraform 向けの HCL2 形式で定義しています。これらのデータファイルは Git などで管理することにより、変更のレビューやテスト、特定時点のリソースにロールバックする、といったことが可能になります。

resource "aws_ecs_cluster" "prd" {
  name = "prd"

  tags = {
    Name        = "prd"
    Service     = "A"
    Environment = "prd"
  }
}

なお、ここではクラウドサービスのリソース管理を例に挙げましたが、Linux などのインスタンスのシステム構成や、ルーター機器の設定などについても同様に可能な限り宣言的にデータファイルで管理します。

継続的にテストをする

インフラのリソースをデータとして宣言的に定義すると、次のようなテストが可能になります。これらのテストは CI サービスを利用して、バージョン管理システムに変更を追加する毎に実行し、レビュー時にはその検証結果が妥当なものであるかを評価します。

  • ツールを使用して定義の内容とリソースの実際の設定の差分を検証する
  • ポリシーツールを使用して、意図しない設定の定義が含まれていないかを検証する

これらのテストを継続的に実行することで、意図しない変更に早期に気付けたり、データファイル以外で発生した設定ドリフトを検出できるようになります。

本番環境、ステージング環境、開発環境を使い分ける

リソースは本番環境、ステージング環境、開発環境の3つの環境に用意して使い分けます。使用する環境とその用途は次の通りです。ステージング環境は本番環境と同等の環境として維持し、リリース前の最終的な動作確認や検証にのみ使用します。レビューを受けていない開発中のコードなどは開発環境に対してのみデプロイします。

環境名 用途
本番環境 利用者にサービスを提供するための環境
ステージング環境 本番環境と同じ構成でテストを実行するための環境であり、リリース用のコードのみをデプロイする
開発環境 開発中のコードの動作を検証するための環境であり、開発中のコードをデプロイする

これらの環境は全て独立したものとして管理し、各環境のリソースが他の環境のリソースに影響しないようにします。また、AWS などのクラウドプロバイダーについては、環境ごとにアカウントを分離します。

シンプルにする

リソースの構成は可能な限りシンプルにします。リソースが少なければ少ないほど運用上の負荷が減るため、少人数で開発と運用をしているカンムにおいてシンプルさを選択することはとても重要だと考えます。具体的には次のような選択をします。

  • クラウドサービスの機能を積極的に活用する
  • 自前で実装せず、オープンソースソフトウェアを活用し、その結果をコミュニティにフィードバックする
  • 最小限のリソースで構成する

いかがでしたでしょうか。社内のマニフェストにはより多くの項目について記載していますが、ここでは紹介しきれないのでまた機会を見つけてご紹介したいと思います。

カンムではセキュアでモダンなクレジットカード決済インフラを作りたいエンジニアを積極的に採用しています!

カンムを支える技術 ~機械学習編~

バックエンドエンジニアの吉田です。カンムでは機械学習を用いた機能開発を担当しています。 バンドルカードでは後払い機能であるポチっとチャージで機械学習が使われています。 去年のAdvent Calendarで石澤さんが カンムを支える技術2020 という記事を書いてくれていましたがそこではあまり触れられていなかった機械学習まわりの取り組みについて簡単にご紹介します。 バンドルカードのサービスはAWSで構築されているので基本的にはAWSに寄せつつも機械学習ではGCPも活用しマルチクラウドで運用しています。

f:id:kanmu-tech:20210610153614p:plain

Data Preparation

DWHとしてBigQueryを利用しています。BigQueryにはバンドルカードのトランザクションデータやFirebaseで取得したアプリのイベントログ、サーバのアプリケーションログ等が集約されておりデータ分析やA/Bテストの集計、障害調査等に使われています。 機械学習においては、時系列データの各種統計量やラグ特徴量の追加といった特徴量エンジニアリングや前処理にもBigQueryを使っています。SQLで処理できるものは基本的にBigQueryに任せて学習用のデータセットを作成しています。

RDSに保存されているバンドルカードのデータは日次でBigQueryに同期しています。 ここでは単純にPostgreSQLのテーブルをほぼそのままBigQueryに転送しているだけで複雑なワークフローは必要なかったことと、GUIも必要なかったのでシンプルなDigdag/Embulkを採用しECS/FargateのScheduled tasksで実行時のみコンテナを立ち上げて処理するような構成にしています。エラーのときは最低限Slackに通知しているのと容易にリトライができるように同期処理が冪等になるようにだけは気をつけています。

続いてGCPではCloud SchedulerからCloud Dataflowのジョブが起動されます。 ここではBigQueryからデータを取得し特徴量エンジニアリングや前処理を行った後、CSVファイルでCloud Storageにアップロードされます。 これも同様に日次で実行されますが、トリガーはCloud Schedulerによる定時実行なので先ほどのBigQueryの同期処理の完了時間からバッファを持たせて実行させています。本当はBigQueryへのデータ同期の正常終了に続けてジョブ実行されるようにフローを組みたいところですが同期に失敗していても大きな問題はないのとそもそもほぼ失敗することはなく実運用上困ってはいないのでよしとしています。ここで作成されたデータセットがモデルの学習で使われます。

Training & Serving

本番で稼働するモデルの再学習は新しく学習データが追加されるタイミング (ポチっとチャージでは月初) や特徴量の追加、パラメータの変更などモデルが更新されるタイミングで行われます。

モデルの学習とサービングはSageMakerを利用しています。学習/推論用のコンテナだけ用意しておけば、データセット、モデル/メトリクス等の成果物、推論用のエンドポイントを一元管理してくれるので非常に便利です。 推論コンテナはGunicorn/Flaskを使ったシンプルなREST APIとなっています。

モデルの学習からサービングまではStep Functionsによって実行されています。すべてのタスクが完了したら、学習結果のログやメトリクスから性能に問題がないことを確認します。 Lambdaから呼び出す推論エンドポイントは環境変数で管理されているので、まず検証環境のエンドポイントを書き換えてテストデータでリクエストを投げてみて想定どおりの結果が返ってくることを確認します。問題がなければ本番環境も切り替えます。モデルの更新はそこまで頻繁に行われるわけではないのでこの辺は自動化せずに慎重に人間が確認をするようにしています。

推論のリクエストパラメータが変更されるような場合にAPIのバージョニングが必要となりますがこれもLambdaで解決するようにしています。

Inference & Monitoring

バンドルカードからはリアルタイムで推論しています。アプリからのユーザの操作であったりバッチ処理であったりトリガーは様々ですが、推論及びその後処理には多少時間を要するので非同期で処理をしています。 RDSにジョブをenqueueすることでWorkerが推論処理を行い、その結果をRDSに書き込みます。

モニタリングに関してはエラーやレイテンシ等の各種メトリクスのMackerelによる監視やRDSのジョブキューの監視を行い異常があった場合はslackやPagerDutyで通知をしています。 また、推論のリクエストと結果に異常がないか、後処理の結果が想定どおりであるかRedashのダッシュボードなどを用意して確認をしています。

推論時の特徴量のデータドリフトの監視の為にSageMaker Model Monitorなども検討しましたがサービスの特性上急にinput/outputの分布が変わったりすることもないし継続的にモデルを学習させているのでそのタイミングで性能劣化や分布に変化がないか確認すれば充分なので見送っています。

Development

開発はローカルマシンで行うことが多いですが、モデルの学習などコンピューティングリソースが必要な場合はGCPインスタンスも使います。 開発環境は基本的にすべてコンテナ化してローカルでもクラウドでも同じ環境で開発ができるようにしています。 パッケージ管理はpoetryを使い、linter/formatterにpysenを使っています。 実験管理はMLflow Trackingを使っています。GitHub issue, データセット, モデル等の成果物, メトリクスを一元管理して過去の実験をすぐに再現できるようにしています。

まとめ

機械学習の立ち上げ当初はモデルや推論APIの開発, バンドルカードへのインテグレーションまですべてひとりで開発していたので、パイプラインの整備までは手が回らず手動でEmbulkを実行して、SageMaker Notebookを使ってモデルを学習させて、、みたいな最低限の状態からスタートしてそこから少しづつ自動化したり改善を行って今のような構成になりました。まだまだ改善したいところはたくさんあります。エンジニア積極採用中です。

kanmu.co.jp

Twitterのフォロワーに一様分布を依頼してみた

はじめに

こんにちは、カンムでバンドルカードの機械学習部分を担当しているfkubotaです。先日、Twitterでこんな感じのアンケートを取ってみました。

f:id:kanmu-tech:20210528204802p:plain

このアンケートの面白いところは、回答者は回答しないと現在の投票成績が見えないというところです。参加者は、「1を取る人が多いハズ、、、であれば2,3,4か?」とか裏を取ろうとします。裏の裏を取ろうとする人もいるでしょう。我ながらTwitterの投票システムを利用した面白い実験だったと思います。

結果はこんな感じ。

951票もいただきました。ありがとうございます。m(_ _)m

そんでもって

もちろん、面白い実験をして、あー面白かったで終わらせるつもりはなく、結果を考察するまでがこの実験の目的です。ちなみにみなさんは、上の結果を見て一様分布だと思いますか? 「全ての数字が同じではないから一様分布ではないよ」という意見もあれば、「まあこの程度の違いなら誤差だと思うよ」という意見もあるでしょう。このあたりどう議論すればいいでしょうか? 多少の誤差とは言いますが、どこまでが許せてどこまでが許せないのでしょうか? やっぱり定量的に賢くやりたいですよね。こういったときに使えるのがいわゆる統計学です。いろいろ手段はあると思うのですが今回は、統計的仮説検定というやつを使ってみます。統計的仮説検定がよくわからない人は、「検定 サイコロ」とか「検定 コイン」とかでググると簡単な例が出てくるので目を通すと良いかもしれません。この記事では、できるだけ検定がわからない人にもわかるようにも書きますが、事前に勉強しておくとより読みやすいと思います。 一様分布っぽさがふわふわしていて定量的にあつかえないもどかしさは、検定という手続きをつかえば定量的に扱えるという事が伝えられたら嬉しいです。

適合度検定

適合度検定(解説はこの記事とか)というのを使います。この検定はある事象がa:b:c:dで起こる時に実験値とのズレが誤差の範囲内であるかどうかを検証する時に使います。今回の場合は、アンケートの結果が一様分布になるはずだという仮説1:1:1:1に対して、実験結果は%で25:18:26:30になっており、投票数は238:181:247:285となっています。これが誤差の範囲なのかそれ以上に意味のある結果なのかを検証します。

計算するにあたりこちらのサイトを利用しました。
今回の条件数は4、観測度数はそれぞれ、238, 181, 247, 285であり、帰無仮説(一様分布である)のもとでの確率は1/4, 1/4, 1/4, 1/4となっているので、そのように入力して計算しました。また、有意水準は5%としています。 結果は以下のようになりました。

統計検定量χ2 = 23.2965
有意水準に相当するχ2の値 = 7.81743
p値 = 0.000035025

適合度検定はχ2検定で片側検定ですので、統計検定量χ^2有意水準に相当するχ^2の値 を上回っていることにより帰無仮説は棄却されます。p値についてちょっと解説すると(怖い...) 、今回の「アンケート結果は一様分布に従う」という帰無仮説が正しいとした場合、今回の結果は、0.0035%程度の確率で起こるよということを示しています。一様分布が正しい分布の場合でも今回の実験結果は起こり得る、ただし「0.0035%という低い確率」でということになります。あまりにも起こる低い確率で起こるのであればそれは仮説が間違っていると考えるのが自然だよね。であれば、5%までは許して5%以下の確率で起こる場合は、仮説が間違っていると考えようということで、有意水準5%を設定しています。

今回の結果としては、仮説が棄却されたので、検定の手続き上ではアンケート結果はどうやら一様分布ではないようです。「一様分布にして」とお願いしたので、一様分布にしようと参加者がいろいろ戦略を立てて投票しその戦略が偏った(似たような戦略を取る人が多かった?)結果なのかなと考えてみたりしました。ただこれ以上のことは今回の検証では何も言えません。今回の適合度検定で言えることは、「一様分布ではなさそう」というところまでです。「適当に投票して」というアンケートだと結果は変わったのかな?と思ったりもしました。

ちなみにどの程度であれば棄却されなかったのかと数字を少し変えて遊んでみたところ、 238, 181, 247, 285238, 230, 247, 285 にしたところ棄却されませんでした。
僕には285の数字がまだ大きく見えて一様分布には見えないなぁと思うのですが、適合度検定にはこれが一様分布に見えるようです。黙って従います。

今回の実験の背景

今回、なぜこのような実験をしようと思ったのか少し話ます。
弊社カンムはバンドルカードというサービスを開発していて、日々新しいデータが大量に入ってきます。僕自身、機械学習を担当しているということもありあらゆるデータに日々触れています。その時、もっとこうしたら良いんじゃないか?とかこれはどう説明できるだろうかなど仮説を立てて検証をしたくなることがあります。しかし僕自身この辺りの知識・業務経験に乏しくて、うまく扱えず歯痒い思いをよくしています。目の前にこれだけ質の高いデータがあるのに自分の力不足のために活用できないのはもったいないなと思い、最近はもっぱら統計的因果推論とか言われる分野を勉強しています。そして、ただ本を読むだけだと退屈だということで第一弾として簡単にできる検定をやってみたという次第です。アンケートご協力頂いた方、とても楽しかったです。ありがとうございました。

次回は、もう少し複雑な統計学の知識を使った分析をやりたいと思います。

お決まりですが

上記にもあるように、もっともっと輝けるのに力を発揮できずに眠ってしまっているデータが弊社には大量にあります。そのデータを統計学などのデータ分析の知識を生かしてさばけるデータアナリストを募集しています。僕もたくさん勉強してこのあたりも少しずつできるようになりたい。
一緒にやりませんか?

https://kanmu.co.jp/jobs/data-analyst/

僕は、2021年2月に入社してからもうすぐ4ヶ月が経ちますがとても入社してよかったなと日々思います。
なによりデータ、テクノロジーに対するリテラシーが高くデータ分析をする身としては、非常に働きやすく何より楽しいです。
社内のほとんどの人がSQL書けちゃったりします(参考記事)。 もちろんエンジニア以外の人もです。
他には、月に一度の TechDayでは、エンジニア・非エンジニアが日頃の業務の非効率をテクノロジーで解決するというイベントがあります。非エンジニア同士がGASがどうだとかSlackのbotがどうだとか議論したりするんです。僕より遥かにGASに詳しい非エンジニアがかなりいます。これは少し悔しい。

上記は一例ですが、こんな感じで企業文化がマッチすれば、楽しく働けることは少なくとも僕は保証しますので興味ある方は、データアナリストに限らず応募してください!!:)

https://kanmu.co.jp/jobs/