(この記事は、「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(在庫)
- ユーザーが商品を購入すると、
- Userのcashを減算する
- 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スキーマを作成
# 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
ルーティングを追加
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
悲観的ロックの実装モジュールを作成
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を使わな買った場合を確認する
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を使った悲観的ロックの実装ケースを見てみよう
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
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 入門」です