【OSS探訪記】hq

背景・モチベーション

Goで並行処理について公式ドキュメントや書籍を読んだが、実際にどのように使われているのか、そしてどのようなプロダクトがあるのか気になった。そこで、awesome-goを見て並行処理をしていそうなOSSを読んでみようと思う。
ざっくり気ままに読んでいきたいので誤読していたり大事なところを読んでいなかったりするかもしれない。OSSの紹介ではなく自己満足でただ読んでいくだけなので、解説についてはそこまで期待しないで頂ければと思う。気になった方が自分も読んでみようかなと思ってもらえると嬉しい。

対象OSS

今回はhqを読む。(さっそくawesome-goに含まれていないOSSなのだが、以前に作者の記事を読んでいて非常に面白かったのでコードも読んでみた)

github.com

これは何をするソフトウェア?

hqはジョブキューのライブラリ。詳細は作者が解説した記事があるのでそれを読むのが正確で理解は早い。あと、この記事は作者の解説記事を読んでいることはある程度前提にして話が進むので、流し読みせずに読まれる方は解説記事を一読された方が良いだろうと思う。

kohkimakimoto.hatenablog.com

hqは下記の特徴がある。

・Goによる実装で、シングルバイナリ

スタンドアロンのHTTP APIサーバー。ジョブのデータベースも読み込みであるため、別途特別な依存を必要としないで動作する

・シンプルでプログラミング言語非依存。HTTP APIでジョブを投入し、ジョブはHTTP POSTメッセージをワーカーアプリケーション(Webアプリ)に送信するというアーキテクチャ

・フロントエンドとしてCLIとWebUIを組み込みでサポート

処理の流れはこんな感じ。

HTTP APIでジョブ(JSON)を投入します。HQはジョブを取り出し、ジョブに記載されたURLにHTTP POSTして、別途用意されたワーカー用のWebアプリケーションにジョブを実行させる、という流れになっています。

解説記事がめっちゃわかりやすい。いきなりREADMEや実装を読む前に解説があると非常に助かる。

実装を追う

ジョブキューとしてのおおまかな流れはわかったので、下記の実装を見てみようと思う。

  • Webサーバとして起動している箇所
  • ジョブをenqueueしている箇所
  • キューの構造
  • ジョブをdequeueしている箇所
  • ジョブの構造

Webサーバの起動

サーバの起動なのでそこまでmain関数から離れて定義されてはいないだろう。ということでmain関数を発見。mainの中でrealMainを呼び出している。

func main() {
    os.Exit(realMain())
}

https://github.com/kohkimakimoto/hq/blob/master/cmd/hq/hq.go#L11-L13

realMainの中ではurfave/cliを使ってCLIアプリとしてコマンドの設定を行いコマンドを実行している。設定に従いapp.Run(os.Args)でコマンドが実行される。

   app := cli.NewApp()
    app.Name = hq.Name
    app.HelpName = hq.Name
    app.Version = hq.Version + " (" + hq.CommitHash + ")"
    app.Usage = "Simplistic job queue engine"
    app.Commands = command.Commands

    if err := app.Run(os.Args); err != nil {
        printError(err)
        status = 1
    }

https://github.com/kohkimakimoto/hq/blob/master/cmd/hq/hq.go#L23-L33

コマンドの一覧は下記。ServeCommandがサーバの起動だろう。

// Command set
var Commands = []cli.Command{
    DeleteCommand,
    InfoCommand,
    ListCommand,
    PushCommand,
    RestartCommand,
    ServeCommand,
    StatsCommand,
    StopCommand,
}

https://github.com/kohkimakimoto/hq/blob/master/command/commands.go#L13-L23

さて、ServeCommandとは何なのか。定義に飛んでみるとserverActionがハンドラとして登録されており、serverActionではListenAndServe()を呼び出しサーバを起動していることがわかる。その過程でserver.NewApp(config)Appを作っているようだ。

var ServeCommand = cli.Command{
    Name:   "serve",
    Usage:  "Starts the HQ server process",
    Action: serverAction,
    Flags: []cli.Flag{
        configFileFlag,
        logLevelFlag,
    },
}

func serverAction(ctx *cli.Context) error {
    config := server.NewConfig()

    if err := loadServerConfigFiles(ctx, config); err != nil {
        return err
    }

    applyLogLevel(ctx, config)

    app := server.NewApp(config)
    defer app.Close()

    return app.ListenAndServe()
}

https://github.com/kohkimakimoto/hq/blob/master/command/serve.go#L11-L34

NewAppを見るとAppという構造にEchoがラップされている。つまりWebサーバの実態はEchoだ。

func NewApp(config ...*Config) *App {
    var c *Config
    if len(config) == 0 {
        c = NewConfig()
    } else {
        c = config[0]
    }

    // create app instance
    app := &App{
        Config:  c,
        Echo:    echo.New(),
        DataDir: c.DataDir,
    }

    app.Echo.HideBanner = true
    app.Echo.HidePort = true
    app.Echo.Server.Addr = app.Config.Addr

    return app
}

https://github.com/kohkimakimoto/hq/blob/master/server/app.go#L58-L78

ついでなのでAppがどういう構造なのかも見てみる。ほとんど設定関連だがDB *bolt.DBStore *StoreQueueManager *QueueManagerなどがある。(GenはジョブのIDを採番するためのもの。本筋とそこまで関わらないのでここで補足しておく)

type App struct {
    // Configuration of the application instance
    Config *Config
    // Logger
    Logger echo.Logger
    // LogfileWriter
    LogfileWriter reopen.Writer
    // LogLevel
    LogLevel log.Lvl
    // Echo web framework
    Echo *echo.Echo
    // AccessLog
    AccessLogFile *os.File
    // AccessLogFile
    AccessLogFileWriter reopen.Writer
    // DataDir
    DataDir string
    // UseTempDataDir
    UseTempDataDir bool
    // DB
    DB *bolt.DB
    // Store
    Store *Store
    // Background
    Background *Background
    // katsubushi
    Gen katsubushi.Generator
    // QueueManager
    QueueManager *QueueManager
}

https://github.com/kohkimakimoto/hq/blob/master/server/app.go#L27-L56

Appがわかったので先ほどのListenAndServeの中身を見ていく。ちょっと行数が多いが基本的にはEchoの起動設定と起動など。ジョブキューとしての本筋と関連しそうなのはメソッドの先頭で行っているapp.Open()だ。

func (app *App) ListenAndServe() error {
    // open resources such as log files, database, temporary directory, etc.
    if err := app.Open(); err != nil {
        return err
    }

https://github.com/kohkimakimoto/hq/blob/master/server/app.go#L203-L207

Openでは先ほどのスニペットのコメントにある通り、Echo以外で必要なリソースの初期化処理を行っている。100行近いコードなので抜粋。下記のように初期化してAppに代入している。QueueManagerについては起動も同時に行っている。これでジョブを受け付け実効する準備が完了するのだろう。

   // setup bolt database
    db, err := bolt.Open(app.BoltDBPath(), 0600, nil)
    if err != nil {
        return err
    }
    app.DB = db
    logger.Infof("Opened boltdb: %s", db.Path())

    // store
    app.Store = &Store{
        app:    app,
        db:     db,
        logger: logger,
    }

    if err := app.Store.Init(); err != nil {
        return err
    }

    // queue
    app.QueueManager = NewQueueManager(app)
    app.QueueManager.Start()

https://github.com/kohkimakimoto/hq/blob/master/server/app.go#L137-L158

ということで、コマンドでserveを渡すとEchoのWebサーバが起動するようだ。

Enqueue

次にこのWebサーバでどのようにEnqueueしているのかを見ていく。これはREADME.mdに記載があり、/jobに対してPOSTするとのこと。 https://github.com/kohkimakimoto/hq/blob/master/README.md#post-job

ではさっそくPOST /jobを探そう。サーバに設定されているハンドラの一覧は下記。POST /jobに対応するのはCreateJobHandlerだ。

func setupAPIHandlers(e *echo.Echo, prefix string) {
    e.Any(prefix, InfoHandler)
    e.GET(prefix+"stats", StatsHandler)
    e.POST(prefix+"job", CreateJobHandler)
    e.GET(prefix+"job", ListJobsHandler)
    e.GET(prefix+"job/:id", GetJobHandler)
    e.DELETE(prefix+"job/:id", DeleteJobHandler)
    e.POST(prefix+"job/:id/stop", StopJobHandler)
    e.POST(prefix+"job/:id/restart", RestartJobHandler)
}

https://github.com/kohkimakimoto/hq/blob/master/server/app.go#L58-L78

CreateJobHandlerの中でリクエストの中身からJobを作ってapp.QueueManager.EnqueueAsync(job)という記述がある。また、app.Store.CreateJobという記述もある。解説記事に書いてあるが、このジョブキューはメモリとKVSのどちらにもジョブを登録するので、これらはその話と対応する処理なのだろう。メソッド全部を載せるにはやや長いので抜粋。

   job := &hq.Job{}
    job.ID = id
    job.CreatedAt = katsubushi.ToTime(id)
    job.Name = req.Name
    job.Comment = req.Comment
    job.URL = req.URL
    job.Payload = req.Payload
    job.Headers = req.Headers
    job.Timeout = req.Timeout

    if err := app.Store.CreateJob(job); err != nil {
        return err
    }

    app.QueueManager.EnqueueAsync(job)

https://github.com/kohkimakimoto/hq/blob/master/server/handlers.go#L50-L64

まずapp.Store.CreateJobを見てみる。メソッドの最初の行に以下のように書いてある。s.dbUpdateに渡している関数のtx *bolt.Txから、Storeboltのクライアントとして定義されていそうな予感。

   return s.db.Update(func(tx *bolt.Tx) error {

https://github.com/kohkimakimoto/hq/blob/master/server/store.go#L74

Storeの定義に飛んでみるとやはりそうだった。app.Store.CreateJobの中で色々やっていそうだけど、boltにジョブを突っ込んでいる処理だとは思うので、一旦これで理解を留めて先に進む。

type Store struct {
    app    *App
    db     *bolt.DB
    logger echo.Logger
}

https://github.com/kohkimakimoto/hq/blob/master/server/store.go#L15-L19

先ほどのCreateJobHandlerapp.QueueManager.EnqueueAsyncに話を戻そう。呼び出しているEnqueueAsyncの定義を読むと、これはジョブをキューに登録しているものだ。また、同時にgoroutineでQueueという変数のchanにjobを積んでいることがわかる。m.RegisterWaitingJob(job)はいまいちわからないので読む必要がありそう。

func (m *QueueManager) EnqueueAsync(job *hq.Job) {
    m.RegisterWaitingJob(job)

    go func() {
        m.Queue <- job
    }()
}

https://github.com/kohkimakimoto/hq/blob/master/server/queue.go#L51-L57

RegisterWaitingJobがやってることはmapにジョブを登録する処理だった。ジョブは待機ジョブとして登録される様子。

func (m *QueueManager) RegisterWaitingJob(job *hq.Job) {
    m.statusMutex.Lock()
    defer m.statusMutex.Unlock()

    m.WaitingJobs[job.ID] = &WaitingJob{
        Job: job,
    }
}

https://github.com/kohkimakimoto/hq/blob/master/server/queue.go#L79-L86

ついでなので、RegisterRunningJobRemoveRunningJobも見ておく。やってることはジョブのステータス変更だが、Mapで管理しているので、各Mapから該当するIDのデータをdeleteしている。

func (m *QueueManager) RegisterRunningJob(job *hq.Job, cancel context.CancelFunc) {
    m.statusMutex.Lock()
    defer m.statusMutex.Unlock()

    m.RunningJobs[job.ID] = &RunningJob{
        Job:    job,
        Cancel: cancel,
    }

    // remove waiting jobs
    delete(m.WaitingJobs, job.ID)
}

func (m *QueueManager) RemoveRunningJob(job *hq.Job) {
    m.statusMutex.Lock()
    defer m.statusMutex.Unlock()

    delete(m.RunningJobs, job.ID)
}

https://github.com/kohkimakimoto/hq/blob/master/server/queue.go#L59-L77

ここまでで、boltにデータを格納しつつQueueManagerQueueというchanにジョブを積む、という流れがわかった。作者の解説記事にもあるが、途中でプロセスが死んでもboltから取り出せるのでジョブをロストしないようにするための設計だ。

Queueの構造

では、さっきから度々登場していたQueueManagerの定義を確認する。先ほどまではEnqueueを見ていたが、ここからはDequeueのための定義も含まれている。

type QueueManager struct {
    App         *App
    Queue       chan *hq.Job
    Dispatchers []*Dispatcher
    WorkerWg    *sync.WaitGroup

    // job status
    statusMutex *sync.Mutex
    WaitingJobs map[uint64]*WaitingJob
    RunningJobs map[uint64]*RunningJob
}

https://github.com/kohkimakimoto/hq/blob/master/server/queue.go#L9-L19

Dispatcherは名前からして明らかにDequeue関連なので一旦後回し。他にはWatingJobRunningJobが気になる。これらの定義を見てみるとJobをラップしたものだった。これらの構造体でラップすることでJobのステータスを表しているようだ。RunningJobの方はジョブとCancelを抱き合わせているので取り回しが便利そう。

type WaitingJob struct {
    Job *hq.Job
}

type RunningJob struct {
    Job    *hq.Job
    Cancel context.CancelFunc
}

https://github.com/kohkimakimoto/hq/blob/master/server/queue.go#L114-L121

キューの構造自体はシンプルだった。状態毎のMapにジョブのIDで登録・削除をしていく。sync.Mapを使えばMap操作前後でロックの確保・開放の処理が必要なくなるのかな、と思うなどした。けれどこれは実装当時はsync.Mapがなかったのかもしれないし、もしかしたらsync.Mapでは実現できない何かがあるかもしれない。これは細かい話なので次に進もう。

Dequeue

さて、QueueManagerが保持していたDispatcherの定義を確認しよう。と思ってみてみたらかなり小さい構造だった。

type Dispatcher struct {
    manager    *QueueManager
    NumWorkers int64
}

https://github.com/kohkimakimoto/hq/blob/master/server/dispatcher.go#L20-L23

構造体の定義の少し下にある実装を見てみるとloopというメソッドがdispatcherには生えており、これがディスパッチャとしての実態だった。ここではQueueからジョブを取り出して、Worker数に応じて同期・非同期に処理を行っていくようだ。dispatchAsyncの方はgoroutineで処理されるのだろう。

func (d *Dispatcher) loop() {
    m := d.manager
    logger := m.App.Logger
    config := m.App.Config

    for {
        job := <-m.Queue
        logger.Debugf("dequeue job: %d", job.ID)

        if atomic.LoadInt64(&config.MaxWorkers) <= 0 {
            // sync
            d.dispatch(job)
        } else if atomic.LoadInt64(&d.NumWorkers) < atomic.LoadInt64(&config.MaxWorkers) {
            // async
            d.dispatchAsync(job)
        } else {
            // sync
            d.dispatch(job)
        }
    }
}

https://github.com/kohkimakimoto/hq/blob/master/server/dispatcher.go#L25-L45

dispatchdispatchAsyncの中身を確認する。どちらの処理も大差はなくgoroutineで処理を行うかどうかが違うだけで、workを呼び出していることがわかる。

func (d *Dispatcher) dispatchAsync(job *hq.Job) {
    manager := d.manager

    manager.WorkerWg.Add(1)
    atomic.AddInt64(&d.NumWorkers, 1)

    go func() {
        defer func() {
            manager.WorkerWg.Done()
            atomic.AddInt64(&d.NumWorkers, -1)
        }()

        d.work(job)
    }()
}

func (d *Dispatcher) dispatch(job *hq.Job) {
    manager := d.manager

    manager.WorkerWg.Add(1)
    atomic.AddInt64(&d.NumWorkers, 1)
    defer func() {
        manager.WorkerWg.Done()
        atomic.AddInt64(&d.NumWorkers, -1)
    }()

    d.work(job)
}

https://github.com/kohkimakimoto/hq/blob/master/server/dispatcher.go#L47-L74

workを見てみる。ここでは主な処理はジョブのステータス変更とジョブの処理を行っている。ジョブの処理がworkだと思っていたけど、本体はrunHttpWorkerだった。結構長いのでコードを適宜抜粋する。ちなみにスニペットと実装されている順序は異なる。(最後のスニペットはdeferで囲まれており記述はメソッドの中間あたり。ただ処理は最後にされるのでスニペットも最後に載せた)

   // keep running job status.
    manager.RegisterRunningJob(job, cancel)
    defer manager.RemoveRunningJob(job)

    if job.Canceled {
        return
    }

    now := time.Now().UTC().Truncate(time.Millisecond)
    // update startedAt
    job.StartedAt = &now
    if e := store.UpdateJob(job); e != nil {
        logger.Error(e)
    }
   // worker
    err = d.runHttpWorker(job, ctx)
       // Update result status (success, failure or canceled).
        // If the evaluator has an error, write it to the output buf.
        if err != nil {
            logger.Errorf("worker error: %v", err)
            job.Success = false
            job.Failure = true
            job.Err = err.Error()
        } else {
            job.Success = true
            job.Failure = false
        }

        // Truncate millisecond. It is compatible time for katsubushi ID generator time stamp.
        now := time.Now().UTC().Truncate(time.Millisecond)

        // update finishedAt
        job.FinishedAt = &now

        if e := store.UpdateJob(job); e != nil {
            logger.Error(e)
        }

https://github.com/kohkimakimoto/hq/blob/master/server/dispatcher.go#L79-L139

では、本丸のrunHttpWorkerを見てみる。やってることは案外単純でJobからPayloadURLを取り出してその通りにnet/httpでリクエストを送っている。これ以上の説明のしようがないのでコードを全部載せてしまった方がわかりやすい気がしてきた。

func (d *Dispatcher) runHttpWorker(job *hq.Job, ctx context.Context) error {
    var reqBody io.Reader
    if job.Payload != nil && !bytes.Equal(job.Payload, []byte("null")) {
        reqBody = bytes.NewReader(job.Payload)
    }

    // worker
    req, err := http.NewRequest(
        "POST",
        job.URL,
        reqBody,
    )
    if err != nil {
        return errors.Wrap(err, "failed to create new request")
    }

    // set context
    req = req.WithContext(ctx)

    // common headers
    req.Header.Add("Content-Type", "application/json")
    req.Header.Add("User-Agent", WorkerDefaultUserAgent)
    req.Header.Add("X-Hq-Job-Id", fmt.Sprintf("%d", job.ID))

    // job specific headers
    for k, v := range job.Headers {
        req.Header.Add(k, v)
    }

    // http client
    client := &http.Client{
        Timeout: time.Duration(job.Timeout) * time.Second,
    }

    resp, err := client.Do(req)
    if err != nil {
        return errors.Wrap(err, "failed to do http request")
    }
    defer resp.Body.Close()

    statusCode := resp.StatusCode

    job.StatusCode = &statusCode
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return errors.Wrap(err, "failed to read http response body")
    }
    job.Output = string(body)

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf(http.StatusText(resp.StatusCode))
    }

    return nil
}

https://github.com/kohkimakimoto/hq/blob/master/server/dispatcher.go#L141-L195

ふむふむ。Dequeueの雰囲気が掴めた。

ジョブの構造

最後にジョブがどういう定義になっているのかを確認する。URLPayloadは先ほどのrunHttpWorkerで見た。他はジョブの状態を表していることがわかる。

type Job struct {
    ID         uint64            `json:"id,string"`
    Name       string            `json:"name"`
    Comment    string            `json:"comment"`
    URL        string            `json:"url"`
    Payload    json.RawMessage   `json:"payload"`
    Headers    map[string]string `json:"headers"`
    Timeout    int64             `json:"timeout"`
    CreatedAt  time.Time         `json:"createdAt"`
    StartedAt  *time.Time        `json:"startedAt"`
    FinishedAt *time.Time        `json:"finishedAt"`
    Failure    bool              `json:"failure"`
    Success    bool              `json:"success"`
    Canceled   bool              `json:"canceled"`
    StatusCode *int              `json:"statusCode"`
    Err        string            `json:"err"`
    Output     string            `json:"output"`
    // status properties.
    Waiting bool `json:"waiting"`
    Running bool `json:"running"`
}

https://github.com/kohkimakimoto/hq/blob/master/hq/structs.go#L25-L45

まとめ

ということでhqをざっと読んでみた。hqは作者のありあまる親切心がREADMEや解説記事ににじみ出ており非常にわかりやすい。OSSを読んでいく上で入門的だ。内容はWebサーバを立ててKVSとchanを活用してジョブを登録・取出を行い、ジョブに保存されているURLとPayloadでリクエストを投げるというものだった。並行処理なのでロックであったり状態管理については非常に参考になる。
なんだかまとめるまでもなく記事の前半で作者の解説記事から引用してきた処理の流れ通りの説明になってしまった。それだけあの解説記事が親切でわかりやすいということで。

次回に読むOSSは決めているのだが、次回がいつやってくるのか怪しいので伏せておく。読んでいて楽しかったので失踪せずに続けたいものだ。

失敗から学ぶRDBの正しい歩き方を読んだ

以前の達人に学ぶDB設計に引き続き下記の本を読んだ。 prelude.hatenablog.jp

失敗から学ぶRDBの正しい歩き方 (Software Design plus)

失敗から学ぶRDBの正しい歩き方 (Software Design plus)

  • 作者:曽根 壮大
  • 発売日: 2019/03/06
  • メディア: 単行本(ソフトカバー)

読んだ背景

それっぽく何が良いのかのわかったので、アンチパターンから学んでいこうという理由から。本書の内容をおおまかに分類すると「設計」「SQL」「管理」に分けられる(主観)。SQLについてはアンチパターンというよりはJOINやロックの仕組みをちゃんと押さえましょうね、という話が主だった。達人に学ぶDB設計に引き続いて設計をもう少し知りたくなったので、今回は「設計」の章についての感想を残しておく。

感想

達人に学ぶDB設計では正規化など正攻法?について学べることが多かったが、この本ではアンチパターンから学んでいくため納得させられる話が多かった。この本を読む前は実際の業務で扱うような複雑さに対処できるほどの知識はなかったので、改めてテーブル設計の難しさを感じた。

失われた状態、フラグの闇、隠された事実

これらの章に共通していたのは、データが一体何なのかわからない、ということである。

失われた状態では、過去の状態がわからないため過去の状態を元にした処理を行うことができない。例でも触れられていたが、状態が失われる設計をしていると返品による払い戻し処理などができなくなってしまう。例えば、消費税や割引額が現在と過去で変わってしまった場合、払い戻しの金額がわからなくなる。このようにその時の状態とその時のデータというものは履歴として控えておく必要があるものがあるのだ。しかし、何でもかんでも履歴として残しておくとデータサイズが増えてしまったり、状態を意識したSQLを発行する必要が出てきて複雑度が増すので、ちゃんと過去の状態・データを参照する可能性があるものだけを見定める必要はある。あと本書では触れていなかったけれど、外部キーを持つようなテーブルであったら、親が履歴を必要とする場合、子も履歴を控えた方が良いケースもあると思うので、テーブルのデータだけで完結せず、より俯瞰して設計する必要がありそう。色んなことを考えさせてくれる章で、自分も気づかずアンチパターンを踏んでいそうだな、とすごく思った。

また、似たようなものとしてフラグの闇という章がある。これは名前の通りだがフラグによる状態管理の話だ。やりがちなアンチパターンとして削除フラグがある。削除フラグが存在すると削除されたデータと削除されていないデータで似たようなデータが増えるため、カーディナリティが低くなる。カーディナリティとは列に格納されるデータにどのくらいの種類があるかを意味しており、カラムのデータの濃度のようなものである。似たようなデータが多い場合は重複があるため濃度が下がる。これによりINDEXが効きづらくなったり一意に選び取れずにメモリ効率が悪くなったり色々起きる。このようなフラグへの対処としては、事実のみを保存できるようなテーブルを設計するということだ。削除フラグでは削除済みのテーブルを作る。

さらに、隠された事実という章では、フラグに似ているが、論理ID・スマートカラムなる話が出てくる。これはIDに意味を持たせたもので、学籍番号がイメージしやすい。学籍番号は入学年度と学部学科のIDを繋げたものであるが、そのように複数の意味を持たせたIDのことを論理IDなどと呼ぶ。単一のカラムだけでなく、あるデータの属性によって入る値が変わるカラムなども同様である。これはカラムではなくテーブル単位で複数の意味を持ってしまっている。属性によってカラムの値が変わるのであれば、属性に応じてテーブルを分けた方が良い。
似たようなアンチパターンとしてEAVが上げられていた。これはtypevalueのように属性名と属性値をカラムとして持つテーブルのことで、取り出すまで属性名や属性値が一切わからない。これも本来であれば属性に応じてカラムやテーブルを用意すべきである。
しかるに、正規化をしっかり行って、カラムやテーブルに複数の意味を持たせないようにしようということである。

強すぎる制約、簡単すぎる不整合

これらの章では最適を目指した結果、それが悪手になってしまっているようなものを扱っていたように思う。DBMS側の制約とアプリケーション側の制約を適切に使うことでより良い設計ができる。

強すぎる制約では、いま現在の仕様に従って制約を作っていくと仕様変更に弱いテーブルになってしまうという話だった。制約はデータの整合性を保つために必要なものであるが、こだわって作りすぎるとイレギュラーな場合にデータを投入することすらできなくなってしまう。例えば、日時のカラムで現在時刻以降しか受け付けない制約があったとする。その場合、過去日のテストデータはどうやって用意すればよいだろうか。また、障害時にデータをどのようにリストアすればよいだろうか。
制約はテーブルだけではなくアプリケーションでバリデーションをかけてあげることで担保する方法もある。これはもちろんDB側での制約ではないため、バグが混入していると不正なデータが格納されてしまう恐れがあるが、それはそれでアプリケーション側の設計で担保すべきことでもある。要は制約をかける場所も段階的に分けることで設計を考えるべき、ということだ。

逆に、正規化を行い完璧な制約を組む話とは裏腹にパフォーマンスのために非正規化したくなる時もある。この場合はアプリケーションで整合性を担保するしかない。あるデータを変更した場合は、それに関連するデータの変更はDBMSではなくアプリケーションの責務となる。そのため、ビジネスロジックが複雑になり時間が経っても意図を残しておける運用が求められる。
これには制約を上手く使うことで回避できることもある。例えば、あるカラムのデータが特定の値だった場合は、このカラムのデータの値はこのようになるはず、という制約を組むことで非正規化されていたとしても不正なデータは混入しづらくなる。カラムに応じて制約を作るような設計は先ほどの複数の意味を持つテーブルなのでは?という疑問が湧いてくるが、パフォーマンスを上げる必要があるテーブルの場合はこの矛盾と付き合いながらベストではなくベターな解決策が必要になる。その際はこの話は非常に参考になるだろうと思った。

キャッシュ中毒

パフォーマンスの鍵だが、アーキテクチャの複雑度を大きく上げる要因でもある。

キャッシュはメモリ上のキャッシュしか考えていなかったが、それ以外にも色々あることを知った。
まず、クエリキャッシュ。これは実行されたクエリの結果がDBMS側で保持されているもの。これにより同じクエリが叩かれた時にすぐにデータを返すことができる。しかし、実行されたクエリの結果が、キャッシュなのか最新情報なのかわからなかったり、テーブルが更新されるとクエリキャッシュはクリアされてしまう。
続いて、マテリアライズド・ビューである。これは先ほどのクエリキャッシュと似ているが、実際にテーブルを作成することが違い。そのため、このマテリアライズドビューに対してINDEXを貼ったり、クエリを発行したりできる。しかし、テーブルを作成しているため、作成の元となるクエリに関係するテーブルに変更があったり、データに大きな変更があると再構築の必要がある。この再構築コストはもちろんテーブルが大きいほどかかってしまうのだが、コストがかかるようなテーブルだからこそキャッシュをしているわけなので、今回の文脈においてもやはり再構築は非常にコストが高い。
最後に、アプリケーションキャッシュである。これはredisやmemcachedなどである。RDBMSを介さないので非常に高速に動作する。しかし、他のキャッシュ同様に状態管理はやはり難しい。

キャッシュの注意点としてはデータの状態を意識することが難しいことに尽きる。参照時にどの状態なのかわかりづらいため、障害時は特に問題の切り分けが難しくなる。また、どのデータがキャッシュされているのか把握しづらいため、デバッグの難易度が高くなる。

キャッシュと付き合っていくためには、キャッシュヒット率や更新頻度を推測・計測をしっかり行っていく必要がある。また、複雑性を極力下げるためにもキャッシュの対象と範囲を見極め、生存期間と更新方法を明確にする。キャッシュがキャッシュであることを自明にしていくことで扱いやすくする。

まとめ

横着して設計を怠るとその時の生産性は一見上がったように見えるがその負債は障害時に支払うことになる。アンチパターンを見ていて思ったが、書籍の情報以上に考えることが多い。きっと筆者は文章以上の経験をやはり積んだ上で、厳選して載せているのだろう。SQLアンチパターンを引き合いにしていることが多くあったので、次回はそれを読んでみようと思う。

競技プログラミング in Go #3

前回 prelude.hatenablog.jp

今回

今回は下記の記事を参考に迷路の最短路特殊な状態の列挙AtCoderの問題を解いていく。 qiita.com

幅優先探索

問題:C - 幅優先探索
解答:Submission #20044513 - AtCoder Beginner Contest 007

考えたこととか

[][]runeにしておくと、距離を保存する時に '1'~'9' までしか入れられないから面倒だった。距離用に [][]intの多次元配列を用意して、距離を見たい時はそっちから参照するようにした。

チーズ(Cheese)

問題:E - チーズ (Cheese)
解答:Submission #20049509 - 第10回日本情報オリンピック 予選(オンライン)

考えたこととか

めちゃくちゃ苦戦した。計4回提出したが、大まかな方針は間違っていなかった。自作のqueueのパフォーマンスが悪かったため、queueについて少し計算を加えるような実装になっているとTLEやMLEが発生してしまっていた。これを通した時はqueueはそこまで原因になっていないと思っていたが、色々試す中で結果的にqueueに対する走査が減ったようでACとなった。これは時間を置いて再度解きたい。

Grid Repainting

問題:D - Grid Repainting
解答:Submission #20050170 - AtCoder Beginner Contest 088

考えたこととか

最大になる時は最短経路を通過する時なので、.の総数から最短経路のマスの数を引けば良い。素直な問題だったのですぐ解けた。

Darker and Darker

問題:A - Darker and Darker
解答:Submission #20050717 - AtCoder Grand Contest 033

考えたこととか

#を通過できないように前処理を入れたらあとはBFSをする。探索時に数字を渡しておいて、maxを取っていけば探索終了時にそれが答えになる。これも素直な問題だったのですぐ解けた。

器物損壊!高橋君

問題:C - 器物損壊!高橋君
解答:Submission #20134936 - AtCoder Regular Contest 005

考えたこととか

コストの扱いを考慮することはできていたけれど、上手く実装できなかった。ARC005には解説がないため、検索をして探したところ01BFSを使うと良いとのこと。なんのことかわからなかったので調べたらすごくわかりやすい記事を発見した。

itliberta.tech

これによりコストが低い選択肢が優先的に処理されるようになるため、障害物を避けた経路をより選択して探索を行うことができる。また、同じノードにたどり着く複数の経路があったとしても、01BFSではコストが低い選択肢を先に処理しているため、訪問済みとしてそのノードにチェックを入れて後続の探索で打ち切ることができる。

キューにpushFrontを実現するため、SliceTricksを見ながら実装を行った。
SliceTricks · golang/go Wiki · GitHub

しかしこれはTLEになってしまう。 f:id:TakumiKaribe:20210214191418p:plain

先ほどのチーズ(Cheese)ではキューの実装が悪かったためにTLEになっておりキューに対する処理を減らすことでACできたが、今回はそうはいかなかった。そのため他の人の01BFSを使った解答を見たら下記のパッケージを使っていることがわかった。
go/src/container/list at master · golang/go · GitHub

SliceTricksの実装だとappend時に配列をたくさん作ることになり、特にpushFrontでは要素の長さだけ毎回メモリを確保してしまっていたのだと思う。なのでcontainer/listのようにlinked listになっていると非常に高速になった。

One-stroke Path

問題:C - One-stroke Path
解答:Submission #20176878 - AtCoder Beginner Contest 054

考えたこととか

探索できたものを数えれば良いのでそう実装していたのだがWAになってしまった。サンプルだけ通っていて何が起きているのかわからず解説を読んで通した。

WAの原因はindexの取り違いだった。for-rangeで回していた二次元配列の値をindexと間違えていて、DFSで誤った値が処理されていた。二次元配列のループはfor-rangeにせずにふつうにiをインクリメントしながら走査してvec[i][j]と値を見た方が直感的な気がした。

解説に載っていたDFSの前後でvisitedをtrue/falseとするコードはすごく良かったので取り入れて解答した。これを読む前はDFSで再帰する際にcopyして渡していたので、かなりメモリ効率が良くなったと思う。

カード並べ

問題:D - カード並べ
解答:Submission #20177931 - 第9回日本情報オリンピック 予選(オンライン)

考えたこととか

数が少ないので全部列挙してしまうのが良さそう。また、数字だと桁を考慮しないといけないので数字で扱うよりも文字列で扱って、mapのlenを取る実装が良さそう。懸念として文字列の結合コストがある。ここだけは気になる。

実際に実装してそれでOKだった。ただ、選択した数字のインデックスに対応して[]boolをtrueにして選び取る実装をしてしまい時間を食ってしまった。[]intにして選択したものをappendしてその順序を維持してmapに突っ込む。AC。

One-stroke Pathで学んだdfsの前後で状態をコントロールする実装ができたので嬉しい。

まとめ

DFSもBFSも特に迷わず実装できているけれど、状態をどうコントロールするのかまだスラスラ書けないので制約が増える度に手が遅くなるのを感じる。ただ書いていてすごく楽しい。

達人に学ぶDB設計を読んだ

背景

年末に読んだ。

達人に学ぶDB設計 徹底指南書

達人に学ぶDB設計 徹底指南書

感想

論理設計と物理設計

物理設計はなかなか馴染みがないので主に論理設計で知識が増えた。
論理設計はドメインRDBにどういう形で落とし込むか決めることだ。設計にはエンティティの抽出、エンティティの定義、正規化、ER図の作成という流れを踏む。
一方、物理設計はテーブル定義、インデックス定義、ハードウェアのサイジング、ストレージの冗長構成決定、ファイルの物理配置決定、という流れを踏む。サイジングはあまり考えたことがなかったため下記の文章は気づきがあった。

データ量というのは、システムの運用開始から、基本的には増えていきます。

たしかに論理設計でテーブルの関係性だけを見ていると当たり前の話だけどわかってなかったな、と思った。

バックアップ

障害によってデータが失われた時に、それを復旧できるようにしておく必要がある。バックアップには主にフルバックアップ、差分バックアップ増分バックアップの3種類がある。

フルバックアップは名前の通りデータを全てバックアップしておくこと。しかしバックアップに要する時間やリソース負荷が高く、整合性を保つためにサービス停止が必要など、運用上のコストが大きいという欠点がある。
差分バックアップは、フルバックアップした日以降はフルバックアップのデータとの差分をバックアップすることでフルバックアップのコストを軽くする手法。欠点としてはリカバリ時に差分をひとつひとつ適用させていく必要があるため、オペレーションが手間になるということだ。
増分バックアップ差分バックアップに似ており、フルバックアップした日以降はその日の変更分のみをバックアップする。

リカバリ

定時で行うバックアップだけだと障害発生直前の状態に戻すことができない。そのため、バックアップファイルだけでなく、トランザクションログを使って巻き戻すことが必要になる。

正規化

正規形とは、一言で言うと、データベースで保持するデータの冗長性を排除し、一貫性と効率性を保持するためのデータ形式です。

正規形についてどのようなものなのか改めて理解するきっかけとなった。定義はネットに沢山転がっているので省略。ここでは関数従属について理解が少し大変だったので整理したことを残しておく。

関数従属性

関数従属性は入力により出力が決定するような性質を指す。これはつまり主キーによってレコードが決定するということである。
発展して部分関数従属性というものがある。主キーが複数のカラムで構成されている時、主キーの一部のカラムで決定するカラムが存在するとする。その場合、そのテーブルは部分関数従属性を満たしていると言う。一方、部分的な関係性がなく主キーを構成する全てのカラムによりレコードが決定する場合は完全関数従属と呼ぶ。
最後に推移的関数従属だ。まず、主キーによって決定したカラムがある。そのカラムが別のカラムのキーとなっているとする。この時、主キーとそのキー、そのキーによって得られるカラムが推移的な関係となっている。名前の通りカラムが推移的な従属関係にある声質を推移的関数従属と呼ぶ。

関数従属性は漢字が並んでいてやや読み飛ばしたくなるが、内容はいたってシンプルだ。従属性が中途半端なテーブルに対して直感的に違和感を感じることはあるが、このように言語化され名前を知っていると解釈ができるし腑に落とせるので良かった。

サロゲートキー

アンチパターンやグレーパターンでは「まぁ、そうだな」と思うような悪手がたくさん載っていたが、サロゲートキーがそこに含まれていたのは意外だった。サロゲートキーは論理的なキーであり本来は不要なため、自然キーによる解決をする方が望ましいとのこと。理由は論理モデルをわかりにくくしてしまうため、何の役割を果たしているのか、ER図を見てもわからないから。

これは正直よくわかっていない。サロゲートキーはもはやサロゲートキー自体に意味が生まれていて、そういうコンテクストは共有されているような気がする。ORMのライブラリでもサロゲートキーが前提となっているようなものも多い。むしろ自然キーによる解決をされたテーブルの方が見かけないような気もしている。が、どうなんだろうか。理論的には全て自然キーで決定できるようにエンティティを抽出すべき、という話なのかも知れないが、現実はもっと複雑だと思う。このあたりは、書籍の性質上、主キーは自然キーであって然るべきと言っておく必要もあるのだろうか。

まとめ

正規化をちゃんと整理できたのは良かったなぁと思った。パフォーマンス関連は詳細まで話がなかったため別の書籍も参照した方が良いと思った。パフォーマンスだけで書籍が出ているくらい深遠なので、むしろ簡潔にしたのだと思う。
アンチパターンもひと通り読むことができたので、なんとなく感じていた違和感の名前を知れたのは良かった。

競技プログラミング in Go #2

前回

競技プログラミング in Go #1 - Prelude

今回

今回は下記の記事を参考に部分和Lake CountingAtCoderの問題を解いていく。 qiita.com

たくさんの数式

問題:C - Many Formulas
解答:Submission #19722074 - AtCoder Regular Contest 061

考えたこととか

+を入れるパターンを全列挙すれば良いと考えた。考慮として先頭の数字の扱いや桁の扱いがあるが、上手く考えきれず結構時間がかかってしまった。けれど、演算子ではなく符号で考えてしまえば先頭の数字を意識せずに処理ができることに気がついた。これに気がつくまで時間がかかってしまった。
解説を読むと、ビット演算で演算子を入れることが可能な箇所を全列挙して計算すると良いと書いてあったので、符号で考えるのは想定パターンじゃないのかも。

全列挙はビット演算で行った。Goで解いたがビット演算はC++と同じなので直感的に記述できた。ただ、ビット演算を書いていてi&1<<j >= 1でビットが立っているか確認する処理でバグらせてしまった。&<<よりも優先順位が高いので、((i&1)<<j) >= 1となってしまっていた。あと、解答した後に思ったけれど、(i>>j)&1とすれば== 1と書けるのでわかりやすいかも。

解説では桁を考慮して10を掛けて足すような処理をしていた。ここはGoのSliceを上手く使って書けたのは良かった。

Train Ticket

問題:C - Train Ticket
解答:Submission #19722660 - AtCoder Beginner Contest 079

考えたこととか

これはすぐできた。数字の入力だけど文字列で受け取り、'0'を引くことで各数字の配列を得る。1パターンだけ見つければ良いので全部試して7になったら打ち切る。

All Green

問題:C - All Green
解答:Submission #19738173 - AtCoder Beginner Contest 104

考えたこととか

自力では解けなかった。要復習。

解説を読んだ上での感想として、得点パターンの確認を怠っていた。回答数ばかりに意識を取られて、どのような得点の積み方があるのか考えていなかった。さらにそこから、得点パターンを踏まえた上で最適化した積み方を考える必要があった。

高橋君とお肉

問題:A - 高橋君とお肉
解答:Submission #19738359 - AtCoder Regular Contest 029

考えたこととか

すぐできた。焼き時間をソートして空いている方を優先して焼けば良い。

派閥

問題:D - 派閥
解答:Submission #19765185 - AtCoder Beginner Contest 002

考えたこととか

自力で解けなかった。

1番大きいCycleを探せではなく1番大きいMeshを探せという問題。互いに知っている特性から、伸びているEdgeの数がそのNodeの関係数の最大値となる。なので、Edge先を番号を配列として控えておき、それをEdge先の各Nodeとも関係性があるのか確認するような処理にすれば、その最大値が答えになると思い実装をした。しかし、これでは解けなかったので解説を読んだ。

解説によると、これは最大クリーク問題と呼ばれる有名な問題とのこと。NP困難。

ja.wikipedia.org

Meshと呼んでいたけど正しくはクリーク。今回の問題は入力値が小さい数字なので全探索できる。例えば1~3までの議員がいるなら、関係性の組み合わせは{},{1},{2},{3},{1,2},{1,3},{2,3},{1,2,3}の計8パターンになる。入力内容のグラフから、このパターンを全部確かめて最大のクリークになるものを探せば良い。

深さ優先探索

問題:A - 深さ優先探索
解答:Submission #19841473 - AtCoder Typical Contest 001

考えたこととか

問題名が答えみたいなもの。実装で思ったこととして、再帰関数の引数はバリデーションせずに渡してしまった方が楽。境界判定とかはインデックスとして使う前でやればOK。

一度通ったところに目印をつけておかないと無限ループするので、通ったところは'#'として通れなくしておく。

埋め立て

問題:B - 埋め立て
解答:Submission #19843166 - AtCoder Regular Contest 031

考えたこととか

さっきの問題には始点と終点があったので一瞬悩んだ。けれど数字が小さいので全探索できると気づいた。全ての座標から開始して、通過した点を'o'から'x'にする。もし埋め立てることが可能なのであれば、1つの座標から全ての'o'の座標を通過できるはずなので、全座標が'x'になったら埋め立て成功。

実装でひと手間必要だったところは2つあった。
まず多次元配列のコピー。全座標について通過した点の値を変える全探索をするため、探索用にコピーをしておく必要がある。しかし、多次元配列内の配列をコピーしても配列の値のポインタは変わらないので、ちゃんと値までコピーしてあげる必要がある。

func copyMatrix(src [][]rune) [][]rune {
    dst := make([][]rune, len(src))
    for i, v := range src {
        dst[i] = make([]rune, len(v))
        copy(dst[i], v)
    }
    return dst
}

あとは探索の開始点を'x'ではなく'o'にしておくことだ。'x'のままで渡してしまうとバリデーションに引っかかってしまう。そのため前処理として開始点を'o'にする1行入れてあげるだけで実装がかなり楽になる。

バウムテスト

問題:B - バウムテスト
解答:Submission #20036890 - AtCoder Regular Contest 037

考えたこととか

自力で解くことができなかった。また、解説を読んでもすぐに解けなかった。不正解だった思考、解説を読んだ後、最終的な解答とその思考の順で書く。

  1. 不正解だった思考
    紆余曲折しながら考えたので取り留めもない思考になっている。文章にすると非常にわかりづらいけれど、その通り思考も整理しきれていなかったのだとわかる。
    まず、1度通ったノードを再度通れるなら閉路なので、それを判定できるようにすれば良さそうだと考えた。しかし、これまでのように通過したノードを通れないノードにすると、通過済みなのか元から通過できないのか判断できなくなってしまう。そのため、ステータスとして未探索と探索中と閉路の3つを用意してあげれば良さそうだと思った。探索中のステータスのノードにぶつかったら、閉路として塗り直す。そうすれば最終的に残る木が答えになる。
    これについてグラフをイメージすると8の字のような形の時に上手くいかなそうだなとか、閉路の位置によってはバグりそうだな、と漠然と思っていた。手は動かしていたもののそれらしい解答が実装できず解説を読むことにした。

  2. 解説を読んで
    詳細は間違っていたけれど、閉路の判定には通過済みノードを用いて再帰的に処理すれば良いというところは合っていた。また、不正解だった思考の時は閉路だとわかった時に再帰を打ち切ろうと思っていたけれど、木としてカウントをしなければ良いだけなので処理を続けてしまって良い。もし途中で打ち切ってしまったとしても、繋がっているのであれば後続のループで判定は可能なので気にしなくて良い。このあたり意識して実装を行いsubmitした。
    解答(誤答):Submission #20032525 - AtCoder Regular Contest 037
    しかしこれは誤答だった。サンプルは全てACだったのでいまいちわからず、ググってみたらまさにというものがあった。
    アルゴリズム - AtCoder ARC 37 バウムテスト ACできない理由がわからない|teratail
    探索で根を考慮できていなかった。グラフによっては1つのグラフを2つのグラフとみなすようになっていた。ということで根を考慮して再submit。
    解答(これも誤答):Submission #20033904 - AtCoder Regular Contest 037
    実装のどこかミスってるんだと思う。当たり前だけど。

  3. 最終的な解答
    解説ブログや他のACの解答を見て修正を行った。シンプルになった。
    解答:Submission #20036890 - AtCoder Regular Contest 037

  4. 感想
    これまでの探索では上下左右のノードにしか遷移しなかったが、今回は任意のノードに遷移できるという点が違いだと思う。上下左右の遷移の場合、通過したノードを通行禁止にしてしまえば無限ループせずに探索できるため、その思考に固執してしまったと思う。特に解説を読む前の思考で、ステータスで色分けをすると考えていたが、その色塗り対象はエッジだった。これは上下左右に遷移するグラフの考え方に近い。
    任意のノードに遷移できるグラフだと回しているループのインデックスの前後でinvalidな状態を判定できないので、別途訪問状態を保持しておく必要がある。それが今回の実装だとvisited := make(map[int]bool)だ。このmapを使って訪問判定をする発想ができなかった。

まとめ

バウムテストは解説を読んでもなかなか解けなかった。周辺ノードではなく任意のノードに遷移できるグラフの閉路判定は別途訪問状態を保持しておく必要がある、という知識はしっかり憶えておいてすぐに頭から引き出して書けるようにした方が良いと思った。
今回解けなかった問題、時間がかかってしまった問題は来月あたりに再度解きたい。

ゼロからはじめるデータベース操作を読んだ

背景・モチベーション

いままでRDBにはあまり関わってこなかったため理解が浅い。DBとなるとKVSを触る機会の方が多かった。RDBが裏側にいたとしてもデータ部隊が別に存在しSQLを書いたりDBの設計に関わることはなかった。SQLを書くとしても、redashでデータを見る時程度で、しかもクエリはコピペで済ませてしまっていた。
さすがにやらないとまずいな、ということで昨年末にようやく読んだ。

現状の知識

以前にredashでクエリを作る時に、合計数を出すクエリが書けないことがあった。そこに別チームの同僚が通りすがり、「countも知らんの!?!??!?!?!???!?!??」と言われた。あの時の顔を思い出すと夜しか眠れない。

環境

テキトーにdockerイメージをpull。

$ docker pull postgresql
$ docker run -e POSTGRES_PASSWORD=password postgresql
$ docker exec -it $(containerのID) bash
$ root@container_id> psql -U postgres

読んだ箇所のおおまかな内容

DBって何だ

これはさすがに理解していたので流し読み。DBMSSQLのパーサーがいて、DBに対して実行し、結果をDBMS経由で返すという流れ。これ以上の深い理解はしていないけれど。

SQLって何だ

SQLに歴史ありという感じだった。SQLはISOで規格を定められた標準SQLというものがあり、普段目にしているSQLはこれだという。また、クエリの種類にもDDL(データ定義言語)・DML(データ操作言語)・DCL(データ制御言語)と分けることができる。それぞれ CREATESELECTCOMMIT などが該当する。詳しく調べるほどの興味が出なかったけど、トリビアとして面白かった。ここに興味が持てると、クエリを高度に抽象化した次元で理解できそう。たぶん。

本書ではクエリはキーワードを大文字、テーブル名を頭文字だけ大文字、あとは小文字としているが、スタイルは議論の余地があるらしい。 techlife.cookpad.com

基本的なCRUD

基本的なので見覚えはあるけど構文を雰囲気で記憶していたので改めて確認すると真新しい感覚になった。

DBの作成
create <DB名>;
テーブルの作成
create table <テーブル名>
(
    <カラム名> <データ型> <制約>,
    <カラム名> <データ型> <制約>,
        .
        .
        .
    <テーブルの制約1>, <テーブルの制約2>, ..., <テーブルの制約N>
);

データ型と制約についてはうっすら理解していたけれど、それだとまた勉強することになりそうだったので公式のリファレンスを眺めた。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 11 データ型
MySQL :: MySQL 5.6 リファレンスマニュアル :: 1.7.3 MySQL における制約の処理

定義の変更
alter table <テーブル名> add column <列名> <データ型> <制約>;
alter table <テーブル名> drop column <列名>;

変更する際は新しいカラムをaddしてから古いカラムをdropする。

データの選択
select <列名>, ..., <列名N> from <テーブル名>;
select distinct <列名>, ..., <列名N> from <テーブル名>;

distinctは複数列にも書けるが、その際は積がselectされる。

NULLの操作

NULLを含む演算をするとNULLになる。特別にIS NULLIS NOT NULLが用意されている。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 3.3.4.6 NULL 値の操作
MySQL :: MySQL 5.6 リファレンスマニュアル :: B.5.5.3 NULL 値に関する問題
MySQL :: MySQL 5.6 リファレンスマニュアル :: 8.2.1.8 IS NULL の最適化

NOT NULLのカラムに対してIS NULLを行うと最適化されるとリファレンスにあった。そもそもNOT NULLのカラムに対してIS NULLつけないだろと思ったけど、ORMとかで人間が組み立てない場合はロジック次第でそういうクエリは生まれそうだなと思った。

集約関数

countsumavgmaxminなど。集約関数はNULLを除外して行われるというのが意外。先ほどまでNULL値を含めた演算はNULLになるという理屈はどこへいったのやら。
特に気をつけたいのはavgだ。NULLが除外されるため、分母がNULLを除いた数になる。

集約関数ではないが、他にはgroup byhavingorder byなど。これらの動作は理解していたけれど、いざ実際に自分で0から書いてみると割と難しい。集約関数と組み合わせて色んなことができる。

更新

insertdeletedropupdateなど。これは大体雰囲気でわかっていたけれど、テーブルのコピーについて初見の知識があった。特定のテーブルを別のテーブルにコピーする際は下記のように書くことができる。

insert into dst (a, b, c) select a, b, c from src;

複雑な問い合わせ

VIEW

VIEWという存在を初めて知った。これは集合を返却する関数のようなもので、from句に続いてテーブルのように使うことができる。
ただし、あくまで関数のようなものなのでVIEWにデータは存在しない。VIEWが評価されるとVIEWの定義であるクエリが実行される。

スカラ・サブクエリ

サブクエリは知っていたが、スカラ・サブクエリという単語は初見だった。これはサブクエリの結果としてスカラ値を返却するものだ。where句では集約関数を使うことができないが、スカラ・サブクエリを利用することで使うことができる。

相関サブクエリ

理解できた気がするけど0から自分で組み立てる自信はないのできっと理解できていない。相関サブクエリはクエリとサブクエリの関係性を紐付けて実行するものだと理解している。

select shohin_bunrui, shohin_mei, hanbai_tanka
from shohin as s1
where hanbai_tanka > (
    select avg(hanbai_tanka)
    from shohin as s2
    where s1.shohin_bunrui = s2.shohin_bunrui
    group by shohin_bunrui
);

これにより各商品分類ごとに分類の平均を超えた商品を選び出すことができる。

集合演算

unionintersectexceptがそれぞれ和・積・差に対応する。これらは同じカラムについてレコードが増減する操作だ。

一方、カラムが増減する結合方法がjoinだ。結合方法によってレコード数も変わることはあるけれど、テーブルを横にくっつけるイメージがある。
inner joinはあるテーブルとあるテーブルに共通するカラムを用いて、それをキーとしたレコードを作成する。外部キーを使った結合とかでイメージがしやすかった。outer joinはキーとしているカラムがテーブルで共通していなくても無理やりくっつけてしまう結合方法。3つ以上のテーブルの結合もfrom句に同じ記述を繰り返すことで実現可能である。
最後にcross joinがある。これはテーブル同士の直積をとる。なのでレコード数が膨大になる。実務でどう使うと嬉しいケースがあるのかさっぱりわからない。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 13.2.9.2 JOIN 構文

InnoDBって何だ

MySQLのリファレンスを見ると、度々InnoDBという単語が出てくる。一体これは何なんだと思いググった。
つまりMySQLのストレージエンジンらしく、いまはオラクルの持ち物らしい。

InnoDB - Wikipedia
mysql-server/storage/innobase at 8.0 · mysql/mysql-server · GitHub

まとめ

何も知らなかったので何もかも新鮮だった。新卒研修でやったはずなんだけどな。
さすがにこのレベルは基礎の基礎なので、リファレンスしなくても書けるようにしたい。

サーバーサイドGo入門 #5 ~redisを使う~

前回はこちら prelude.hatenablog.jp

今回作るもの

redisを用いてReadのレスポンスを早めましょう。

ユーザーIDの追加

redisの導入にあたりまずユーザーIDをTODOに追加します。このユーザーIDをredisのキーとして用いTODOを保存します。

type TODO struct {
    gorm.Model
    // これ
    UserID      int       `json:"userID"`
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

ユーザーIDの追加に伴い、TODO一覧APIをユーザーIDを指定して取得するものに変更しましょう。

func list(c *gin.Context) {
    userID := c.Param("user_id")

    todos := make([]TODO, 0)
    if err := gormDB.WithContext(c).Find(&todos, "user_id = ?", userID).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos/:user_id", list)
    router.POST("/todo", add)
    return router
}

redisの導入

redis clientの初期化

では早速redisを導入しましょう。使用するライブラリは下記です。import時にv8をつける必要があることに注意しましょう。 github.com

redisのクライアントもgorm同様にグローバルに定義し、main関数で初期化します。

// これと
var redisClient *redis.Client

func main() {
    _gormDB, err := gorm.Open(mysql.Open("root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true"), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    sqlDB, err := _gormDB.DB()
    if err != nil {
        panic(err)
    }

    defer sqlDB.Close()

    if !_gormDB.Migrator().HasTable(&TODO{}) {
        if err := _gormDB.Migrator().CreateTable(&TODO{}); err != nil {
            panic(err)
        }
    }

    gormDB = _gormDB

    // これ
    redisClient = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })

    log.Println(setupRouter().Run(":8080"))
}

このクライアントを用いるように修正します。

redisを使う

今回はredisを下記のように使います。

  • list:ユーザーIDをキーとしたデータがredisにあればそれを使用。なければDBから取得し、redisに保存。
  • add:ユーザーIDをキーとしてredisに保存されているTODOを削除。

ちなみに、redisにsetする際に下記のinterfaceを要求されます。

type BinaryMarshaler interface {
    MarshalBinary() (data []byte, err error)
}

encoding - The Go Programming Language

そのため、set前にjsonにencodingしている点に注意してください。実際のコードはこのようになります。

func list(c *gin.Context) {
    userID := c.Param("user_id")

    todos := make([]TODO, 0)
    if b, err := redisClient.Get(c, userID).Bytes(); err == nil {
        if err := json.Unmarshal(b, &todos); err == nil {
            c.JSON(http.StatusOK, todos)
            return
        }
    }

    if err := gormDB.WithContext(c).Find(&todos, "user_id = ?", userID).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    j, err := json.Marshal(todos)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    if err := redisClient.Set(c, userID, j, 5*time.Minute).Err(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    if err := gormDB.WithContext(c).Create(&todo).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    if err := redisClient.Del(c, strconv.Itoa(todo.UserID)).Err(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todo)
}

redisを動かす

redisのイメージを取得し、動かします。

$ docker pull redis
$ docker run --rm --name redis -p 6379:6379 redis redis-server --appendonly yes

curlで確認

下記の通りcurlを行い確認します。2度目のTODO一覧の取得時間が非常に短いこと、TODOを追加した後のTODO一覧の取得が1度目の取得と同じ程度に遅くなることを確認してください。

$ curl -X POST http://localhost:8080/todo -d \
'{"userID": 1,
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system", 
  "dueDate": "2022-12-31T00:00:00Z"
}' | jq
$ curl http://locahost:8080/todos/1 | jq

コード全文

// main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/go-redis/redis/v8"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var gormDB *gorm.DB
var redisClient *redis.Client

type TODO struct {
    gorm.Model
    UserID      int       `json:"userID"`
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

func list(c *gin.Context) {
    userID := c.Param("user_id")

    todos := make([]TODO, 0)
    if b, err := redisClient.Get(c, userID).Bytes(); err == nil {
        if err := json.Unmarshal(b, &todos); err == nil {
            c.JSON(http.StatusOK, todos)
            return
        }
    }

    if err := gormDB.WithContext(c).Find(&todos, "user_id = ?", userID).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    j, err := json.Marshal(todos)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    if err := redisClient.Set(c, userID, j, 5*time.Minute).Err(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    if err := gormDB.WithContext(c).Create(&todo).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    if err := redisClient.Del(c, strconv.Itoa(todo.UserID)).Err(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todo)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos/:user_id", list)
    router.POST("/todo", add)
    return router
}

func main() {
    _gormDB, err := gorm.Open(mysql.Open("root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true"), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    sqlDB, err := _gormDB.DB()
    if err != nil {
        panic(err)
    }

    defer sqlDB.Close()

    if !_gormDB.Migrator().HasTable(&TODO{}) {
        if err := _gormDB.Migrator().CreateTable(&TODO{}); err != nil {
            panic(err)
        }
    }

    gormDB = _gormDB

    redisClient = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })

    log.Println(setupRouter().Run(":8080"))
}

まとめ

現実のサーバーサイドの開発ではキャッシュは非常によく行われるものと思います。今回はredisを深く理解して使うことよりも、まずGoで扱ってみて最初の1歩のハードルを低くすることを目的としました。

redisを使いキャッシュしていますが今回のコードでは速くなりません。そもそも処理が少ないため、redisとのやり取りによるオーバーヘッドの方が高くつきます。

雑談

redisの後はgoroutineとgPRCの導入を書こうと思っていましたが失踪しそうです。