blog.syfm

徒然なるままに考えていることなどを書いていくブログ

一年を振り返り… (2019)

去年は 👇

syfm.hatenablog.com

去年の 6 月ごろからほとんど毎日、雑に日記をつけていたのでこの振り返りもだいぶ書きやすくなった。

journey.cloud

Journey というアプリを使っている。各プラットフォーム向けにアプリがあり、Google Drive にエクスポートできたりするのが良いし、普通に使いやすい。毎日 22 時頃にリマインダーを飛ばしていて、一日の振り返りを雑に書く。だいたい一行二行くらいだけど気が向いてるときとか印象的なことがあった日は長文を書いていたときもあった。酒飲んでいて眠いときとかは書かないことも良くあった。このくらい雑だと意外と習慣化できる。

1 月

f:id:ktr_0731:20191222001322j:plain
今年もここから

今年も年越しを友人らと過ごし、初日の出を見てきた。ここ数年は元旦に雨の日がなくて良い。
会津では基本的に研究を進めながら、友人らとスマブラやったり (自分はクソ弱い) 、go-fuzzyfinder という OSS の開発をしたりしていた。去年の 10 月から一切働いておらず、無駄に時間はあったので自由気ままな生活をしていた。

OSS については、Evans が年明けまでに 500 stars はいけなかったけど年明け直後に突破した。また、12 月には 1000 stars を突破することもできた。Evans についてはまた別の機会に記事を書きたい。

読んだ本

五等分の花嫁(1) (週刊少年マガジンコミックス)

五等分の花嫁(1) (週刊少年マガジンコミックス)

日の出待ちにいた漫画喫茶で読んでた。最初からずっと二乃派です。

A Philosophy of Software Design

A Philosophy of Software Design

  • 作者:John Ousterhout
  • 出版社/メーカー: Yaknyam Press
  • 発売日: 2018/04/06
  • メディア: ペーパーバック
今年読んだなかで二番目に良かった本。書評に関しては別途記事を書いてるのでそちらも併せて。

syfm.hatenablog.com

go-fuzzyfinder を開発しているときに文字列処理に興味を持ったので読んだ。

2 月

f:id:ktr_0731:20191222004926j:plain
今年は雪が少なかった

この時期は卒論執筆も終盤に差し掛かり、結構忙しかった記憶がある。また、それは卒業が目前に迫っているということでもあったので、最後の思い出づくりに卒論の合間に会津で好きだったところにもう一度行ったりしていた。ごはんはあおやま、HERO'S DINER、よしのや食堂、YAKi 家、丸忠あたりが、居酒屋は天竜、盃爛処、福住あたりが好きです。

引っ越しもこの時期にした。4 年間住んでいた家は毎日夕焼けを眺めることができたのですごく気に入っていた。会津はなにもないけど風景が本当に綺麗。

卒論を書き終わったあと、ktr0731/go-fuzzyfinder という、fzf のような機能を提供する Go ライブラリを公開した。100 stars 超えていて、それなりに需要があったんだな〜と思った。

読んだ本

やはり俺の青春ラブコメはまちがっている。12 (ガガガ文庫)

やはり俺の青春ラブコメはまちがっている。12 (ガガガ文庫)

  • 作者:渡 航
  • 出版社/メーカー: 小学館
  • 発売日: 2017/09/21
  • メディア: 文庫
俺ガイルの最新刊が出たので読んでいた。前巻と間が空きすぎて忘れていたのでそちらも読み直した。

アニメ観てハマったので全巻買って読んだ。

冬目景作品の新刊。冬目景にしてはかなり早く続刊を出していて驚いた。

[試して理解]Linuxのしくみ ~実験と図解で学ぶOSとハードウェアの基礎知識

[試して理解]Linuxのしくみ ~実験と図解で学ぶOSとハードウェアの基礎知識

図やコードが多くて分かりやすかった。普段このレイヤーはあまり意識しないのでこういった本は非常にありがたい。

3 月

f:id:ktr_0731:20191222011223j:plain
サンシャイン 60 展望台

3 月は東京への完全に引っ越しも一段落し、新しい街を歩いたり、東京にいる友人にあったりしているだけの生活をしていた。あとは卒業式とその前日の LT イベントのために 2 日だけ会津に戻ったりもしていた。4 年間本当にあっという間だった。
鎌倉、江ノ島に行ったりもしていた。最後に行ったのは中学校の修学旅行だったのでだいぶ記憶が薄くなっていたけど、当時見た場所や入った店とかをなんとなく思い出せて懐かしい気分になった。すごく良かったけど、昔行ったときより人がかなり増えていた気がした。

読んだ本

現代法学入門 (有斐閣双書)

現代法学入門 (有斐閣双書)

  • 作者:
  • 出版社/メーカー: 有斐閣
  • 発売日: 2005/03/01
  • メディア: 単行本

社会がアラートループ事件とか Coinhive 事件とかで不穏だったので自衛のために最低限の知識はつけておきたいと思ったので読んだ。この手の本にしてはかなり読みやすく、題材も面白かった。

金融ドメインをざっと理解したかったので手にとったが、実際は Fintech 寄りの本だったのであんまり読んだ意味はなかった気がする。

Kubernetes についてハンズオン的に進めながら読める本。サンプルは Azure だけど GCP でも問題なく進められる。

4 月

f:id:ktr_0731:20191222013655j:plain

社会人一年目として働き始めた月。この月は仕事以外でなにをやったのかあまり覚えていないけど多分何もやってなかったんだと思う。自分自身、環境が急激に変わることがあんまり好きではないのでそのせいで消耗していたのかもしれない。入社エントリでも書いたけど新卒研修を途中で切り上げて早期配属を希望したのもこの月で、月末にはチームに合流し GW を迎えた。

Google Photo の写真眺めててユーフォの劇場版めちゃくちゃ良かったのを思い出した。あとなぜか新宿御苑の写真があった。なんで行ったんだっけ?

読んだ本

京都アニメーションでアニメ化予定の本。結構面白かった。

響け! ユーフォニアム 北宇治高校吹奏楽部、決意の最終楽章 前編 (宝島社文庫)

響け! ユーフォニアム 北宇治高校吹奏楽部、決意の最終楽章 前編 (宝島社文庫)

  • 作者:武田 綾乃
  • 出版社/メーカー: 宝島社
  • 発売日: 2019/04/17
  • メディア: 文庫
響け!ユーフォニアムの完結編の前編。前編なのでとにかく不穏だったという印象が強い。

5 月

f:id:ktr_0731:20191227022421j:plain

GW に土善旅館で開発合宿をしていた。自分は Evans のフルリプレイスをしていたような気がする。土善旅館については前々から聞いていたけどかなり備品が揃っていて何一つ不自由なく開発できて最高だった。ブログも書いた。

syfm.hatenablog.com

月半ばに Go Conference 2019 Spring があり、自分のプロポーザルが採択されていたのでスライド作りや発表練習を結構していた。go-fuzzyfinder について話し、結構好評だったので嬉しかった。

読んだ本

主観で判断するのではなく本質を見ましょうね、といった内容の本。

6 月

f:id:ktr_0731:20191227023033j:plain
日光東照宮に行った

親族との旅行で日光に行っていた。日光東照宮とか二荒山神社とか、あのあたりはかなり多くの歴史ある神社仏閣があって巡っているだけでかなり楽しい。 宿泊した湯西川も良かった。鬼怒川よりさらに奥地なのでさらに寂れていた。途中通った鬼怒川温泉街の寂れ具合も最高だった。会津地方の芦ノ牧温泉街感ある。 *1

仕事の方では企業側の人間として母校の会津大学の LT イベントに参加して発表したりしてきた。後輩や、院進や留年した友人らも元気そうで良かった。 また、配属チームで 5 月から開発していた静的 MPM 決済が無事リリースでき、一段落できた。

jp.merpay.com

読んだ本

夢で見たあの子のために(4) (角川コミックス・エース)

夢で見たあの子のために(4) (角川コミックス・エース)

読んだけどどんな内容だったか忘れてしまった。

亜人(14) (アフタヌーンKC)

亜人(14) (アフタヌーンKC)

相変わらずかなり熱い展開が続いていて先が読めない。

ブラック・ラグーン(1) (サンデーGXコミックス)

ブラック・ラグーン(1) (サンデーGXコミックス)

社会勉強するために全巻買って再読した。

青のフラッグ 1 (ジャンプコミックスDIGITAL)

青のフラッグ 1 (ジャンプコミックスDIGITAL)

エグい。

響け! ユーフォニアム 北宇治高校吹奏楽部、決意の最終楽章 後編 (宝島社文庫)

響け! ユーフォニアム 北宇治高校吹奏楽部、決意の最終楽章 後編 (宝島社文庫)

  • 作者:武田 綾乃
  • 出版社/メーカー: 宝島社
  • 発売日: 2019/06/22
  • メディア: 文庫
響け!ユーフォニアムの完結巻。めちゃくちゃ面白かったので早く映像でも見たい…。

クレジットカード決済ドメインについて扱っている本で、メルペイのシステムのクレジットカード決済ドメインと似ているので頭の中で単語や全体像を整理できて良かった。

7 月

f:id:ktr_0731:20191227023334j:plain
友達と映画館行ったらたまたまやってたユーフォの原画展

入社して 3 ヶ月が経ち、雇用が切られなかったので入社エントリを書いたりしていた。 この月は体調が悪い日が多く、日記にも体調が悪かったとか WFH したとか書いてあったけどなにがあったのかよく分からない。いつの間にか治っていた。

天気の子が公開されたので観に行ってきた。どこか既視感を感じたけど面白かった。前々から新海誠

が好きだったみたいだけど最近六本木ヒルズ森タワーもお気に入りに追加されたっぽい。

読んだ本

入門 監視 ―モダンなモニタリングのためのデザインパターン

入門 監視 ―モダンなモニタリングのためのデザインパターン

今月から OnCall に入ることになり、運用もしていくようになったけど、運用の経験・知識がなかったので監視の基本的な知識を身に着けたくて読んだ。

8 月

f:id:ktr_0731:20191227023648j:plain

月半ばにはメルペイ側のエンジニアとしてサイバーエージェントとの合同イベントで登壇していた。Go modules に関する知見は今まであまりなかったので良い発表だったかなと思った。 あとはお盆だったので実家に久しぶりに帰り、地元の友人と飲みに行ったりしていた。地元は魚の美味しい地域なので最高。

読んだ本

BANANA FISH ANOTHER STORY (1) (小学館文庫)

BANANA FISH ANOTHER STORY (1) (小学館文庫)

アニメ版の BANANA FISH を観たので、そのアフターストーリーを読むために買った。

小説 天気の子 (角川文庫)

小説 天気の子 (角川文庫)

  • 作者:新海 誠
  • 出版社/メーカー: KADOKAWA
  • 発売日: 2019/07/18
  • メディア: 文庫
映画の補完として読んだ。

9 月

f:id:ktr_0731:20191229212817j:plain
客室からの眺めが本当に最高だった

この月は ISUCON があった。自分たちのチームは毎年旅館で ISUCON をやっているので、今年も伊豆の方へ旅行に行った。かなり最高なので来年もやりたい。前回は伊香保、今年は伊豆 (土肥) だった。どうせ行くなら毎回別な温泉地が良いので来年も熟考したい。

また、10 年来のネット上での友人らとのオフ会が実現したのもこの月だった。当時たくさんいた、ネットでのみ関わりがあった人たちももはやほとんどいなくなってしまったけれどそれでもこうして集まれる友人がまだ 10 人弱いるのはびっくりすると同時にすごく嬉しい。また来年開催したいね、といった話をしたので来年も楽しみ。

読んだ本

進撃の巨人(1) (週刊少年マガジンコミックス)

進撃の巨人(1) (週刊少年マガジンコミックス)

無料キャンペーンやっていたので読み始めたけど、読む時間がなくて 1-2 巻くらいしか読めなかった…。

10 月

f:id:ktr_0731:20191229220027j:plain
六本木ヒルズ展望台

この月は特に印象に残ったイベントはなく、静かな月だった気がする。日記を見る感じ、ハードワークをしていたわけではないけど結構仕事に手一杯で、家に返ったらぼーっと過ごして眠くなったら寝るみたいな生活をしていたっぽい。本も読めていなかった。
ただ、これはあんまり良くないなーと思ったので会社の OKR の個人 KR に OSS 活動や技術インプット・アウトプットを入れて意識して時間を有効に使うようにした。

11 月

f:id:ktr_0731:20191229221427j:plain

この月は仕事がかなり慌ただしく、いろいろなことが目まぐるしく動いていた。というか仕事のことと飲んでいたことくらいしか覚えてない…。あとは苦しい出来事があり、急だし予定も何も立てていなかったけど 2 日くらい有給を取って実家に帰ったりしていた。東京は会津より実家に帰りやすく、2 時間くらいで行けるので良い。

読んだ本

20世紀少年―本格科学冒険漫画 (1) (ビッグコミックス)

20世紀少年―本格科学冒険漫画 (1) (ビッグコミックス)

突然カンナのラビット・ナボコフが読みたくなったので買って再読した。

サマータイムレンダ 1 (ジャンプコミックスDIGITAL)

サマータイムレンダ 1 (ジャンプコミックスDIGITAL)

本屋でなんとなく目に入ったので買った漫画。ここ数年読んだ漫画の中で一番面白い (そもそもそこまで読んでないけど)。やっぱループとかタイムリープものがめちゃくちゃ好きなんだよなー。

5 月からゆっくり読み進めていた本で、今年読んだ本の中で一番良かった。以下の記事でも挙げている。

syfm.hatenablog.com

12 月

f:id:ktr_0731:20191229224702j:plain

社内の開発合宿で箱根に行ったりしていてすごく楽しかった。自分は The Amazon Builders' Library の記事とか go-fuzzyfinder のメンテ・機能開発とかをしていた。 仕事は相変わらずゴタゴタしていたけどなんとか仕事を納めつつ、実家に帰り中学の同窓会をした。高校の同窓会は全然ないけど中学は結構な頻度でやってる気がする。次は GW かな、みたいな話もした。 あとは大晦日にいつものメンバーで集まって年を越すだけ。

読んだ本

ブルーピリオド(1) (アフタヌーンコミックス)

ブルーピリオド(1) (アフタヌーンコミックス)

かなり面白い。印象に残るセリフが多く、すごく共感できる。

まとめ

社会人になり、モノリシックなソフトウェア開発ではなくマイクロサービスの開発に関わるようになったことや、運用・保守に関わるようになったことでとにかくあらゆることを学ぶことができた濃い一年だった。 所属しているチームも居心地がよく、残業もほとんどしていないので精神衛生 (心理的安全性?) もかなり良く楽しく働けている。

社会人になっても学生の時と同じくらいプライベートでもコードを書いたりアウトプットができているのでこれからも継続したい。逆にインプットはもっと増やしたいと思う。ずっとコード書いていたい。

ただ、基本的に仕事中心になってしまっていたのはあんまり良くないと強く感じているので来年はもう少しプライベートな時間を有効に使えるようにしたいなーと思う。

*1:芦ノ牧のほうがより寂れているけど…

pkg/errors から徐々に Go 1.13 errors へ移行する

Go 1.13 からの新しい errors

Go 1.13 からエラー処理が強化され、errors パッケージに As と Is 関数が追加されました。これにより、今までは pkg/errors のようなライブラリを使用しなければ実現するのが難しかった、型情報を保持したままの wrap/unwrap が可能になりました。

サードパーティのライブラリに依存せずに実現できるのは非常にありがたい反面、移行コストが高そうでなかなか移行できていないプロジェクトも多いかと思います。また、pkg/errors ではスタックトレース情報を保持してくれますが、Go 1.13 errors では保持してくれないといった問題もあるため、完全移行が難しいといったケースもあると思います。

pkg/errors の Go 1.13 errors 対応

しかし最近、以下の Pull Request がマージされ pkg/errors で wrap されたエラーは Unwrap() error を実装するようになりました。

github.com

これを利用すると pkg/errors で wrap されたエラーであっても Go 1.13 の IsAs が機能するようになります。 例えば今までの pkg/errors だと以下のコードは false を出力しますが、この変更が入っていると true を出力するようになります。

package main

import (
    "errors"
    "fmt"

    pkgerr "github.com/pkg/errors"
)

func main() {
    berr := pkgerr.New("err")
    err := pkgerr.Wrap(berr, "wrapped")
    fmt.Println(errors.Is(err, berr))
}

この変更を適用したい場合、まだリリースタグが切られていないためコミットハッシュを指定して pkg/errors を更新する必要があります。最近の pkg/errors はあまり開発がアクティブではないため、コミットハッシュで固定してもあまり問題にはならないと思います。

2020 年 1 月 13 日追記: v0.9.0 がリリースされました。以下を実行すると更新することができます。

$ go get -u github.com/pkg/errors

これにより、既存コードのエラーの wrap 処理はそのままにしつつ errors.Is や errors.As といった標準ライブラリの機能を使えるようになりました。

pkg/errors にどういった変更が入ったのか

そもそも、pkg/errors にどういった変更が入ったことでこういった振る舞いをするようになったのでしょうか?
Go 1.13 の errors パッケージの IsAs のドキュメントには以下のような記述があります。

The chain consists of err itself followed by the sequence of errors obtained by repeatedly calling Unwrap.

また、 Is の実装を読んでみると、3 つのことを繰り返し行っています。

  1. 比較可能であれば比較する
  2. Is(error) bool を実装していれば呼び出す
  3. Unwrap 関数に err を渡して unwrap する
func Is(err, target error) bool {
    if target == nil {
        return err == target
    }

    isComparable := reflectlite.TypeOf(target).Comparable()
    for {
        if isComparable && err == target {
            return true
        }
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
        // TODO: consider supporting target.Is(err). This would allow
        // user-definable predicates, but also may allow for coping with sloppy
        // APIs, thereby making it easier to get away with them.
        if err = Unwrap(err); err == nil {
            return false
        }
    }
}

Unwrap も非常にシンプルで、ただ型アサーションをしているだけです。

func Unwrap(err error) error {
    u, ok := err.(interface {
        Unwrap() error
    })
    if !ok {
        return nil
    }
    return u.Unwrap()
}

基底のエラーに到達するまで繰り返し同値判定を試みていることがわかります。 ここで重要なのは渡されたエラーが Unwrap() error を実装していればそれが呼び出されるということです。 実際、pkg/errors の Go 1.13 errors に対応した Pull Request の変更も Unwrap() error を実装しただけの非常にシンプルなコードとなっています。

github.com

Go 1.13 errors に対応したライブラリの例

pkg/errors 以外にも Unwrap() errorIs(error) boolAs(interface{}) bool を暗黙的に実装するようになったライブラリがいくつかあります。ここではその一部を紹介します。

標準ライブラリ

当然ですが、標準ライブラリのいくつかのパッケージにも Go 1.13 errors への対応が入っています。
例えば、 net/url パッケージには Error という、発生したエラーに加えて補助的な情報も持つことのできるエラー型がありますが、この型が保持している基底のエラーを Unwrap で取り出せるようになりました。

golang.org

同じように os パッケージの PathErrorLinkErroros/exec パッケージの Error にも Unwrap() error メソッドが追加されています。

cloud.google.com/go/spanner

GCP のライブラリの spanner パッケージは Go 1.13 errors に対応しており *1、返ってくるエラーは Unwrap() error を実装しています。
また、 GRPCStatus() *status.Status というメソッドも実装しているため、以下のように組み合わせることもできます。

var s interface{ GRPCStatus() *status.Status }
if errors.As(spannerErr, &s) {
  fmt.Printf("%v\n", s.GRPCStatus())
}

たとえ pkg/errors で wrap されていたとしてもこのコードは正しく動作します。

まとめ

このように、pkg/errors が使われているコードベースであっても最新のコミットを取り込めば大きな変更なしに Go 1.13 errors に移行できることがわかりました。 pkg/errors は最近はあまりアクティブではなく、Go 1.13 errors でのエラーハンドリングが主流になっていくと思うので移行を検討して観る価値はあると思います。

*1:まだバージョンは切られていませんでした…

アプリケーションにおけるデータ不整合との戦い

これは Aizu Advent Calendar 2019 の 15 日目の記事です。14 日目は uzimaru0000 さん、16 日目は kacky__917 さんです。


はじめに

世の中には日々たくさんの価値ある Web サービスが生まれていますが、その価値を正しく提供するにはアプリケーションが正しく動かなければなりません。
たとえばアプリケーションは適切なユーザに適切なリソースを提供しなければならず、エラーを返す際は十分に定義された仕様に沿って返し、UI 側ではユーザに適切なメッセージを表示しなければなりません。
実際のところ、これらを厳密に実現するのは非常に困難ですが、アプリケーションにはこれら以上に複雑な問題が常につきまといます。

現在の Web アプリケーションはほとんどが分散システムの一形態です。例えばクライアントとサーバや、サーバとデータベースがネットワークを介して接続されていればそれは分散システムと呼べますし、分散システム特有の問題と対峙しないといけないことを意味します。分散コンピューティングの落とし穴 には「ネットワークは信頼できる (The network is reliable)」という落とし穴が挙げられていますが、多くのアプリケーションはネットワークを無条件に信頼しています。

エラー

データベースを持つシステムや、複数のバックエンドサービスがネットワークを介して接続されているようなシステムでは非常に様々なエラーが発生します。たとえばデータベースへの負荷が高まったことにより書き込みを受け付けられなくなったり、データベースのクラッシュ、サーバとデータベース間の通信でネットワークエラーが発生し、書き込みを行えなかったりといった問題が挙げられます。 バックエンドのサービスを提供するサーバがただ一つしかない場合、バックエンドは内部エラーを返すだけですが、サービスが複数になると途端に問題は複雑になります。

フロントエンドからの API リクエストを受け付けるバックエンドのサービス A がサービス B へ内部的に API リクエストを行い、サービス B がデータベースへ書き込みを行い、書き込んだデータオブジェクトをクライアントに返すようなシーケンスを考えてみます。ちょうど以下のような図になります。

f:id:ktr_0731:20191214232456p:plain

どういったエラーケースがあるか一つ考えてみましょう。
たとえばデータベースが何らかの問題によりダウンしており、サービス B がデータの書き込みに失敗し、エラーが返るようなケースはすぐに思いつくと思います。このケースを見るだけだとそこまで複雑には感じませんが、実際には以下のようなケースが起こりえます。

  1. サービス A へのリクエストに失敗した
  2. サービス A からサービス B へのリクエストに失敗した
  3. データベースへの書き込みが失敗した (上記の例)
  4. データベースへの書き込みは成功したがネットワークエラーによりサービス B には失敗として返った
  5. データベースへの書き込みは成功したがサービス B がサービス A にレスポンスを返す途中でネットワークエラーにより失敗として返った
  6. データベースへの書き込みが成功したがサービス A がクライアントにレスポンスを返す途中でネットワークエラーにより失敗として返った

このようにさまざまなケースでエラーが起こることがわかるかと思います。 さらに、データベースへの書き込みが成功しているにも関わらず失敗となってしまっている 4 から 6 のケースは、データベースに保存された結果とレスポンスの内容に不一致が生じてしまっています。もしこの結果をユーザが見た場合、強い混乱を与えることになるでしょう。

当然ながらアプリケーションで事前に定義したエラー *1 やデータベースエラー・ネットワークエラー以外のエラー *2 も発生しうるため、列挙したエラーケースはほんの一部に過ぎません。

複数のデータベースとデータ整合性

先程の例ではデータベースはただ一つしかありませんでしたが、サービス A もデータベースを持ち、サービス A がデータベース A へ書き込みを行ったあとにサービス B へリクエストを行うものとしてみると複雑さはさらに増加します。

f:id:ktr_0731:20191214232146p:plain

  1. サービス A へのリクエストに失敗した
  2. データベース A への書き込みに失敗した
  3. サービス B へのリクエストに失敗した
  4. データベース B への書き込みに失敗した
  5. データベース A への書き込みが成功したがサービス A には失敗として返った
  6. データベース B への書き込みは成功したがネットワークエラーによりサービス B には失敗として返った
  7. サービス B がサービス A にレスポンスを返す途中でネットワークエラーにより失敗として返った
  8. データベース A の書き込みに失敗し、サービス A がクライアントにエラーレスポンスを返す途中でネットワークエラーにより失敗として返った
  9. すべて正常に処理されたが、サービス A がクライアントにレスポンスを返す途中でネットワークエラーにより失敗として返った

先程の例では状態を保持しているデータベースが一つであり、その他のコンポーネントはステートレスでしたが、状態が複数のデータベースに保存されるようになるとデータ不整合の問題がかならず発生します。 たとえば 3、4、5、8 のケースでは、データベース A にはデータが書き込まれているのにも関わらずデータベース B にはデータが書き込まれておらず、データ不整合が発生しています。本来であれば、リクエストが失敗として返ったのであればデータは一切書き込まれておらず、リクエストが成功として返ったのであれば両方のデータベースにデータが書き込まれているという状態であるべきです。
不整合を許容できるデータ *3 なのであれば問題ありませんが、そうではない場合、どうにかして不整合を正しい状態に修復 (repairing) する必要が出てきます。

データ不整合の修復

Write Repair

Write Repair は書き込み処理を行う際にデータ不整合の修復を行う仕組みです。上記のようなシステムで Write Repair を実現する最もメジャーな手法はリトライを行うことです。

リトライ

リトライは可用性を高めるためにもしばしば使用されるため馴染みが深いかと思います *4
発生したエラーがタイムアウト等の一時的なものの場合、API リクエストのリトライを行うことで解決し、正常なレスポンスを受け取れる可能性があります。当然ながらクライアントエラーなどのリトライ不可能なエラーはリトライしても意味がないため、バックエンドサービスにはリトライ可能なエラーと不可能なエラーを明確に区別し、ハンドリングする責務があります。

多重リクエストと同一性

しかし、タイムアウト等のエラーでは、バックエンドの処理が成功した直後にクライアントがタイムアウトと判断してしまい、リトライを行ってしまうようなケースが発生することがあります。 その結果、一度目のリクエストが正常に処理されているにも関わらず再度 API リクエストをしてしまうため、二重にリクエストされることになります。 これがリソースの作成、例えばブログでいう記事の投稿であればまったく同じ記事が二重に投稿されてしまうことになります。 そのくらいであればユーザは許容できるかもしれませんが、これが決済システムであれば多重決済となり深刻な問題となります。

また、サーバが全く同じボディやパラメータを持つ複数の API リクエストを受け取った際、それらが別々なものなのかリトライや重複して送られたもの (e.g. ボタンを短期間で複数回クリックした) ものなのかを判別する手段はありません。

リトライを行いつつ、多重にリクエストが処理されるのを防ぐ方法はいくつかあります。たとえば冪等性 *5 を担保するための ID を用意し、その ID が一致していれば同一のリクエストとみなす方法があります。
これは Stripe 等では Idempotent Requests と呼ばれるパターンで、ID は Idempotency Key と呼ばれます。

stripe.com

サーバはどのリクエストが同一かを判断する術を持たないため、Idempotency Key 生成の責務は常にクライアントが持つことになります。

Idempotent Requests

クライアントはユーザ等からの入力をもとに Idempotency Key を生成します。キーの同一性さえ担保できていれば良いため、入力のハッシュ値でも良いですし、UUID v4、ユニークな入力など、特に制約はありません。
サーバはクライアントから受け取った Idempotency Key をチェックし、すでに同じ Idempotency Key を持つリクエストを受け取っていた場合、冪等性を担保できるように処理を行います。例えばブログの記事を新規に作成し、レスポンスにその内容・詳細を返す API へのリクエストであれば、冪等性チェックを行い、すでに記事が作成されていた場合はデータベースに保存されているデータをもとにレスポンスを生成し返却します。Idempotency Key が同一であるため新たに記事の作成は行いません。

このようにシステム全体で冪等性が担保された設計になっており、各バックエンドサービスがそれらのエラーを適切にハンドリングし、リトライ可能なレスポンスステータスを返していれば、そのクライアントはリトライを行うことにより正常に処理できるようになります。各リクエストには安全にリトライができるように Idempotency Key を付与します。

トランザクションの状態管理

バックエンドサービス内で冪等性を担保した実装を行うにあたって、どのように冪等性のチェックを行えばいいのか、という問題があります。
例えば、トランザクション (原子性を担保したい処理群) に含まれる操作が 1 つのデータベースへの書き込みのみで、そのプライマリキーを決定論的に求めることができる (例えば入力から求められる) のであれば事は簡単です。単純にプライマリキーでデータベースから参照し、データが存在すれば以前に処理されている (= 今のリクエストは多重に送られたもの) ということになります。

ただし、現実のシステムはそんなに単純ではなく、処理の途中で外部のシステムへアクセスしたり、データベースへの書き込みや更新を複数回行うものがほとんどです。データベースへの永続化以外のすべての処理が終わってから永続化すれば原子性を担保することはできますが、トランザクションが非常に大きくなってしまうので、ある一つの処理が失敗しただけではじめからやり直すことになってしまい現実的ではありません。

そこで、ステートマシンパターンを使うとトランザクションをより粒度の細かい単位に分割して、リトライの単位をそれぞれに分離することができます。 まずトランザクション開始時に初期状態をデータベースに永続化します。次の状態 (dst) へ遷移するためのアクションの処理を行い、すべての処理が成功した場合に dst へ状態を更新します。

クライアントがリトライをしてきた場合は、まずトランザクションの今の状態を取り出します。状態が終了状態ではない場合、以前の処理が正常に完了しなかったことを意味するので今の状態から次の状態に遷移できるように処理を再開します。冪等性もステートの単位で担保すれば良くなります。

Asynchronous Repair

Write Repair によりデータ不整合を修復できるようにはなりましたが、それでもリトライ試行時間内で解決できなかったケースは発生します。また、エラーハンドリングにバグがありリトライ不可能なエラーとしてクライアントに返してしまったためリトライされなかったようなケースもあるかもしれません。

そこで Write Repair に加えて Asynchronous Repair が必要になります。Asynchronous Repair は通常の処理とは独立して非同期で動き、データ不整合を修復する仕組みです。 Asynchronous Repair では例えば定期的に実行されるバッチを用意し、バッチは処理されたデータ (上記の例であればデータベース A と B のデータ) を確認し、不整合があれば修復します。

また、上記のようにトランザクションの状態管理をステートマシンにより行っている場合は終了状態になっていないトランザクションを取り出し、終了状態に進めるように処理を再開することでデータの修復を行うことができるケースもあります。

データ競合の回避

Asynchronous Repair の考え方は非常にシンプルですが、システムが提供しているメインの処理とのデータ競合に気をつける必要があります。特定のデータに対してバッチと API リクエストによる処理が同時に書き込もうとしている場合、API リクエストの処理結果をバッチが上書きしてしまうといったことが起こりえます。そのため、バッチの実装や実行タイミングを設計するときはデータ競合を十分に考えなければなりません。
また、バッチのスケジューラが exactly once *6 な実行をしているとも限りません。たとえば Cloud Scheduler のドキュメントには以下のような記述があります。

Cloud Scheduler は、「少なくとも 1 回」を基本に処理を行うよう設計されています。つまり、ジョブがスケジュールされると、Cloud Scheduler はそのジョブのリクエストを少なくとも 1 回は送信します。まれに、同じジョブの複数のインスタンスがリクエストされる可能性があります。このためリクエスト ハンドラはべき等である必要があります。またコードを記述する際は、このような状態が発生した場合に有害な副作用が発生しないようにする必要があります。

バッチが複数同時に起動したことにより新たなデータ不整合が発生したら元も子もありません。

TCC パターン

アプリケーションのユースケースの都合により、「確実に処理できる」と事前に分かった上で別のタイミングでその処理を実行したいようなケースがあります。

こういったケースを実現するためのパターンとしては TCC パターンが有名です。TCC は Try/Confirm/Cancel の略で、実行できるかのチェック (仮処理) を行う Try、本処理を行う Confirm、仮処理を取り消す Cancel の 3 つのインターフェースです。Try が成功した場合、Confirm は最終的には必ず成功しなければなりません。例えば Confirm 時にデータベースがダウンしていたとしても、データベースが復帰した際の処理の確定を保証しなければなりません。アプリケーションの都合上、長時間に渡る失敗が許容できない場合は (たとえば Asynchous Repair で) Cancel を呼び出し、処理を中止することになります。

Stripe の API では charge (請求) を作成するための APIcharge を確定するための API の 2 つが用意されていますが、これは TCC パターンの Try と Confirm を満たす API です。

おわりに

この記事では一般的な Web サービスを模した図を元に、分散システムではいかに予測できないエラーが起こりうるかを見てきました。また、データ不整合が発生した場合、どのようにしてそれを修復し、結果整合性を担保するかの一例を紹介しました。

自分の働いている会社ではマイクロサービスアーキテクチャを採用しており、上記で紹介したことのほぼ全てを実際に経験してきています。
マイクロサービスアーキテクチャはまさに数多くのサービスがネットワークを通じて接続されている分散システムです。マイクロサービスアーキテクチャを採用するということはこういった大きな複雑さと常に向き合っていかないといけないことを意味しています。

とはいえ、これらの複雑さと向き合うということは難しくも非常にチャレンジングで楽しいのでぜひ体験してみてください。IT 系の会社で働いているのであれば必ず遭遇することになると思います。

参考

最後にこれらへの知識を深めるために役に立った記事や本を紹介します。

aws.amazon.com

最近生えてきた "The Amazon Builders' Library" という知見の塊のようなサイトの一記事です。シングルマシンでは単純に動くアプリケーションであっても分散システム上に構築するとどれだけ複雑になるかが書いてあります。マイクロサービスを開発する前に読んでおくとコーディングの際に意識できるようになるかもしれません。

medium.com

Airbnb において多重決済を防ぐためにどういった基盤を構築しているかを紹介した記事です。Idempotency Requests に関する詳細な説明もあるのでぜひ一読してみることをおすすめします。

docs.microsoft.com

クラウドネイティブなアプリケーションをつくる上で役に立つ設計パターン集です。それぞれのパターンの使い所、メリットとデメリットが詳しく書いてあります。たくさんあるので聞いたことがあるパターンや興味のあるパターンをかいつまんで見ていくといいかもしれません。

データベースをはじめとするデータシステムのアーキテクチャについて非常に広い範囲にわたって書かれている本です。5 章の「レプリケーション」、7 章の「トランザクション」、8 章の「分散システムの問題」、9 章の「一貫性と合意」あたりを読むと分散システムの難しさと面白さが嫌というほどわかると思います。自分はわかりました。

*1:不正なパラメータでリクエストされた、認証エラーなど

*2:アプリケーションのクラッシュ、高負荷でリクエストを受け付けられないケースなど

*3:データベース A をキャッシュ用途のみに使用している場合など

*4:逆にリトライを実装しないアプリケーションは一度のタイムアウトによりアプリケーションの可用性を落としてしまうことになるため、現実的ではありません

*5:あるリクエストを複数回投げたとしても常に同じ結果を返す性質

*6:常に一回だけ実行されるということ。他には at least once や at most once がある。