Edited at

ElixirでSI開発入門 #1 Ectoで悲観的ロック

More than 1 year has passed since last update.

(この記事は、「Elixir or Phoenix Advent Calendar 2017」の4日目です)

昨日は、@piacere さんの「ExcelからElixirをマスター#1:行の「並べ替え」と「絞り込み」でした

今日は、実際のプロダクト開発のお話です。


はじめに

Elixirで本格的に開発を初めて2ヶ月がたった。

fukuoka.exでの活動だけでなく、実際のプロダクト開発で実装した経験を元に

SI的なプロダクト開発での実装パターンをElixirでどう実現するか書き記していこうと思う

第1回目はシステム開発の基本 DB操作のうち、排他制御に関するお話を前後編でお送りします。



楽観的ロックと悲観的ロック

まずは代表的な排他制御の概念だが・・・

すでに大変丁寧に説明いただいている投稿があるのでそちらを参照させていただこうと思う。

排他制御(楽観ロック・悲観ロック)の基礎



Ecto.Multiでトランザクション制御

ではElixirのORマッパーであるEctoを使って悲観的ロックの実装をしてみよう。

悲観的ロックを実装するためには、更新対象をロックしてから全ての更新SQLが終わるまでロックを維持するためトランザクション制御が必要となる。

しかしサンプルなどでよく見るEctoの実装では、SQL毎に自動的にCOMMITが実行されており、

ロックを維持することができない。

よって、複数のSQLを同一トランザクションで実行する為、Ecto.Multiを使用する。



実装の前提

今回は以下の実装を例に考える


  • 残高(credit)をもつユーザー(User)

  • 単価(price)と数量(amount)をもつStock(在庫)

  • ユーザーが商品を購入すると、


    1. Userのcashを減算する

    2. Stockのamountを減算する



  • 上記2つの更新は同一トランザクションで実行する必要がある

また、以下の環境で実装した


  • Elixir     v1.6.1

  • Phoenix v1.3.2

  • Ecto      v2.2.10

  • PostgreSQL v10.2



開発手順 


プロジェクト〜モデルの作成

まずはPhoenixプロジェクトを作成する

> mix phx.new ecto_multi_sample --no-brunch

> cd ecto_multi_sample

リポジトリの設定を確認しDBスキーマを作成


config/dev.ex

# Configure your database

config :ecto_multi_sample, EctoMultiSample.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: "postgres",
  password: "postgres",
  database: "ecto_multi_sample_dev",
  hostname: "localhost",
  pool_size: 10

> mix ecto.create


モデルを作成してマイグレーションを実行

> mix phx.gen.html Accounts User users name:string age:integer mail:string    credit:integer

> mix phx.gen.html Items Stock stocks category:string name:string  price:integer amount:integer
> mix ecto.migrate

ルーティングを追加


ib/ecto_multi_sample_web/router.ex

defmodule EctoMultiSample.Router do

use EctoMultiSample.Web, :router

scope "/", EctoMultiSample do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
resources "/users", UserController
resources "/stocks", StockController
end
end



Phoenixを起動

> mix phx.server

http://localhost:40000 にアクセス


Userを登録する

http://localhost:4000/users


Stockを登録する

http://localhost:4000/stocks

悲観的ロックの実装モジュールを作成


lib/ecto_multi_sample/cart_action.ex

defmodule EctoMultiSample.CartAction do

import Ecto.Query, warn: false
alias EctoMultiSample.Repo
alias EctoMultiSample.Accounts.User
alias EctoMultiSample.Items.Stock
alias Ecto.Multi
alias CartAction
def submit(order) do
#Multi使わない実装
end
def submit_multi(order) do
#Multiを使った実装
end
end


Multiを使わなかった場合(トランザクション制御なし)

まずはEcto.Multiを使わな買った場合を確認する


lib/ecto_multi_sample/cart_action.ex

def submit(order) do

user = Repo.get(User, order.user_id)
stock = Repo.get(Stock, order.stock_id)
# 所持金を引く
uesr_credit = user.credit - ( order.amount * stock.price )
# 在庫を減らす
stock_amount = stock.amount - order.amount
user_changeset = User.changeset(user, %{credit: uesr_credit})
stock_changeset = Stock.changeset(stock, %{amount: stock_amount})
case Repo.update(user_changeset) do
{:ok, user} ->
IO.puts("# User updated successfully.")
{:error, changeset} ->
IO.puts("# User updated error.")
end
case Repo.update(stock_changeset) do
{:ok, stock} ->
IO.puts("# Stock updated successfully.")
{:error, changeset} ->
IO.puts("# Stock updated error.")
end
end


実行時のSQLトレースは以下のようになる

> iex -S mix

iex(1) > alias EctoMultiSample.CartAction
iex(2) > order = %{amount: 2, stock_id: 1, user_id: 1}
iex(3) > CartAction.submit(order)

2018-04-16 22:13:11.820 PDT [56416] LOG:  execute ecto_34: SELECT u0."id", u0."name", u0."age", u0."mail", u0."credit", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1)

2018-04-16 22:13:11.820 PDT [56416] DETAIL:  parameters: $1 = '1'
2018-04-16 22:13:11.827 PDT [56418] LOG:  execute ecto_66: SELECT s0."id", s0."category", s0."name", s0."price", s0."amount", s0."inserted_at", s0."updated_at" FROM "stocks" AS s0 WHERE (s0."id" = $1)
2018-04-16 22:13:11.827 PDT [56418] DETAIL:  parameters: $1 = '1'
2018-04-16 22:13:11.832 PDT [56417] LOG:  execute <unnamed>: UPDATE "users" SET "credit" = $1, "updated_at" = $2 WHERE "id" = $3
2018-04-16 22:13:11.832 PDT [56417] DETAIL:  parameters: $1 = '660', $2 = '2018-04-17 05:13:11.82837', $3 = '1'
2018-04-16 22:13:11.837 PDT [56419] LOG:  execute <unnamed>: UPDATE "stocks" SET "amount" = $1, "updated_at" = $2 WHERE "id" = $3
2018-04-16 22:13:11.837 PDT [56419] DETAIL:  parameters: $1 = '48', $2 = '2018-04-17 05:13:11.83358', $3 = '1'

毎回トランザクションIDが変わっているため、同一トランザクションで実行できていなことがわかる



Multiを使った場合(トランザクション制御あり)

次に本題のMultiを使った悲観的ロックの実装ケースを見てみよう


lib/ecto_multi_sample/cart_action.ex

def submit_multi(order) do

result = Ecto.Multi.new
|> Multi.run( :get_order, EctoMultiSample.CartAction, :buy, [order] )
|> Repo.transaction()

case result do
{:ok, value} ->
IO.puts("# User & Stock updated successfully.")
{:error, changeset} ->
IO.puts("# User & Stock updated error.")
end
end



lib/cart_action.ex

def buy(%{}, order) do

[user] = User
|> where([u], u.id == ^order.user_id)
|> lock("FOR UPDATE")
|> Repo.all()
[stock] = Stock
|> where([s], s.id == ^order.stock_id)
|> lock("FOR UPDATE")
|> Repo.all()
# 所持金を引く
uesr_credit = user.credit - ( order.amount * stock.price )
# 在庫を減らす
stock_amount = stock.amount - order.amount

user_changeset = User.changeset(user, %{credit: uesr_credit})
stock_changeset = Stock.changeset(stock, %{amount: stock_amount})
Repo.update(user_changeset)
Repo.update(stock_changeset)
{:ok, "update OK."}
end



実行結果を見てみよう

> iex -S mix

iex(1) > alias EctoMultiSample.CartAction
iex(2) > order = %{amount: 2, stock_id: 1, user_id: 1}
iex(3) > CartAction.submit_multi(order)

2018-04-16 22:13:25.177 PDT [56420] LOG:  execute POSTGREX_BEGIN: BEGIN

2018-04-16 22:13:25.181 PDT [56420] LOG:  execute ecto_549: SELECT u0."id", u0."name", u0."age", u0."mail", u0."credit", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) FOR UPDATE
2018-04-16 22:13:25.181 PDT [56420] DETAIL:  parameters: $1 = '1'
2018-04-16 22:13:25.184 PDT [56420] LOG:  execute ecto_2: SELECT s0."id", s0."category", s0."name", s0."price", s0."amount", s0."inserted_at", s0."updated_at" FROM "stocks" AS s0 WHERE (s0."id" = $1) FOR UPDATE
2018-04-16 22:13:25.184 PDT [56420] DETAIL:  parameters: $1 = '1'
2018-04-16 22:13:25.185 PDT [56420] LOG:  execute <unnamed>: UPDATE "users" SET "credit" = $1, "updated_at" = $2 WHERE "id" = $3
2018-04-16 22:13:25.185 PDT [56420] DETAIL:  parameters: $1 = '320', $2 = '2018-04-17 05:13:25.184953', $3 = '1'
2018-04-16 22:13:25.187 PDT [56420] LOG:  execute <unnamed>: UPDATE "stocks" SET "amount" = $1, "updated_at" = $2 WHERE "id" = $3
2018-04-16 22:13:25.187 PDT [56420] DETAIL:  parameters: $1 = '46', $2 = '2018-04-17 05:13:25.186421', $3 = '1'
2018-04-16 22:13:40.190 PDT [56420] LOG:  execute POSTGREX_COMMIT: COMMIT

トランザクション制御(BEGIN〜COMMIT)が実行されており、

トランザクションIDが全て同一であることが確認できる。



まとめ


  • 悲観的ロックの実装にはトランザクション制御が必要

  • トランザクション制御を行うためEcto.Multiを使う

  • 更新行ロックをとるにはlock("FOR UPDATE")を使う

次回は、もう一つの代表的な排他制御である楽観的ロックを実装する予定です。

明日は、@twinbee さんの「Elixir GenStage #1 入門」です