Go
Redis
golang
isucon
Go2Day 23

Go で Redis の Sorted Set を楽に扱いたい

TL;DR

  • Go で Redis を扱う redigo はシンプルな反面コード量が多くなりがち
  • Redis を使ってやりたいことはある程度パターンがある
  • 同じようなコードを毎回書かないために github.com/izumin5210/ro をつくった
  • ro を利用することで記述を最低限にしコードの見通しを良くしつつ快適に Redis を扱えるようになる

Redis + Golang あるある

Golang で Redis を扱うには redigo もしくは go-redis が人気を二分している印象がある.
自分は redigo をよく使うのだが,redigo は Redis にコマンドを発行する + Go の型に変換するための最低限の API を持っている,が,ほんとに最低限しかない.
また,以下に挙げるようにredigo も Redis 自身もクセが強いので結構しんどい面がある.

  • キーの命名の一貫性を保つのが面倒
  • コマンドが独特で覚えられない
    • ZREVRANGE foo 10 20 で何件返ってくるんだっけ?
    • 毎回 redis-cli 等で動作確認している気がする
  • ボイラープレートが多い
    • オブジェクト全体を Hash に
    • ソートに使いたいスコアをそれぞれ Sorted Set に
    • 複数アイテム保存するときに pipeline にしないとパフォーマンスが…
    • 失敗したら DISCARD しないと…
    • あ,複数アイテムまとめて保存したいときはどうするんだろう…?
    • 複数アイテム取得したいときは…?
type Post struct {
    ID        int64  `redis:"id"`
    Title     string `redis:"title"`
    UserID    int64  `redis:"user_id"`
    CreatedAt int64  `redis:"created_at"`
}

func SavePost(p *Post) error {
    conn := pool.Get()
    defer conn.Close()

    var err error
    err = conn.Send("MULTI")

    key := fmt.Sprintf("Post:%d", p.ID)

    if err == nil {
        err = conn.Send("HMSET", redis.Args{}.Add(key).AddFlat(p)...)
    }   
    if err == nil {
        err = conn.Send("ZADD", "Post/created_at", p.CreatedAt, key)
    }
    if err == nil {
        err = conn.Send("ZADD", fmt.Sprintf("Post/user:%d", p.UserID), p.CreatedAt, key)
    }

    if err == nil {
        err = conn.Do("EXEC")
    } else {
        conn.Do("DISCARD")
    }

    return err
}

「面倒」「毎回調べる」みたいなのは生産性だだ下がりなので解決したい.
たとえば ISUCON など,時間と脳のリソースが限られているときにこんなことに煩わされたくない.

github.com/izumin5210/ro

上に挙げた問題を解決するために,github.com/izumin5210/ro というライブラリを作った.

Redigo は Hash を Struct にマッピングするところまではやってくれたりするが,それ以上のことはしてくれない.
ro は read / write のコマンド群を適当に抽象化した関数を持つ Store オブジェクトと, Ordered Set から値を読み出すためのコマンドを組み立てるクエリビルダを提供する.

つかいかた

定義

ふつうの Struct に ro.Model を embed する.
ro.Model はいくつか関数を持っている.
GetKeySuffix() は override 必須の関数で,Hash に保存するときのキーの後半を決める.ちなみに前半部分はデフォルトでは構造体名になる.
GetScoreMap() は任意.これが返す map のキーを Sorted Set のキーの後半部分,値をスコアとして Sorted Set に保存する.

type Post struct {
    ro.Model
    ID        int64  `redis:"id"`
    Title     string `redis:"title"`
    UserID    int64  `redis:"user_id"`
    CreatedAt int64  `redis:"created_at"`
}

func (p *Post) GetKeySuffix() string {
    return fmt.Sprint(p.ID)
}

func (p *Post) GetScoreMap() map[string]interface{} {
    return map[string]interface{}{
        "created_at":                     p.CreatedAt,
        fmt.Sprintf("user:%d", p.UserID): p.CreatedAt,
    }
}

初期化

ro.Newredis.Conn を返す関数と扱いたい構造体のポインタを渡す.
(前者に関しては redsync は関数ではなく interface を受け取るような実装になっていたので,そっちに合わせたくなる気がしている)

pool = &redis.Pool{
    Dial: func() (redis.Conn, error) {
        return redis.DialURL("redis://localhost:6379")
    },
}

store := ro.New(pool.Get, &Post{})

保存

Set() に構造体を渡すだけ.複数渡したりもできる.
いくつ渡しても,とりあえず勝手に pipelined transaction にしてくれるのでパフォーマンス問題は起きない.
(ただ内部ですこしリフレクション使っているので,そういう意味では redigo を生で使うよりは遅い)

store.Set(&Post{
    ID:        1,
    UserID:    1,
    Title:     "post 1",
    Body:      "This is a post 1",
    CreatedAt: now.UnixNano(),
})

取り出し

とりあえずキーわかっててオブジェクト全体を読み出したいときは Get() に突っ込んであげるだけで返ってくる.これも Set() と同様に複数アイテムをまとめて取り出せる.

Query() という関数から条件をチェインできるようになっている.
これを Select() に渡すと検索, Count() に渡すと件数を数えてくれる.
ZREVRANGE とか ZCOUNT は一旦忘れてよくなる.

これらの処理も当然 pipeline でおこなわれる.

post := &Post{ID: 1}
store.Get(post)

posts := []*Post{}
store.Select(&posts, store.Query("created_at").GtEq(now.UnixNano()).Reverse())


cnt, _ := store.Count(store.Query("user:1").Gt(now.UnixNano()).Reverse())
fmt.Println(cnt)

使い勝手

ISUCON の練習 〜 予選を通して,なんかしら Redis に突っ込んで… みたいなコードを頻繁に書いていて面倒になったのが作ったきっかけ.ISUCON 7 本戦のちょっと前に作り始めて,本戦で実戦投入した.実際に殆どのデータをMySQL → Redis or メモリに載せたんだけど,移行が非常にスムーズでとくにハマることもなかった.

オチとしては今年の本戦は big.Int を扱いまくるアプリだったので Redis にいれると marshal / unmarshal のコストが大きくなっちゃうのでオンメモリにしたほうがいいみたいなやつだった.ざんねん.

まとめ

Golang で Redis をいい感じに扱うための github.com/izumin5210/ro というライブラリを紹介した.
ボイラープレートを排除しコード量を減らせ,可読性にも寄与できる.

ただ.Redis をあまり変な使い方すると後でひどい目にあうことが多いのでご利用は計画的に という感じです.
ISUCON みたいな環境では活躍できると思います.

ref: Redis 本番障害から学んだコードレビューの勘所 - Qiita