34
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-04-27

(この記事は、「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 入門」です

34
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?