Ecto/SQL - 原子获取/设置

Ecto/SQL - atomic get / set

我有 credits 列需要检查(如果值 > 0)并同时减去 1(多个 processes/connections 将同时轰炸数据库,因此需要以原子方式完成)。

仅启动事务并从那里获取 + 更新行就足够了吗?喜欢:

Repo.transaction(fn ->
  user = Repo.get(User, user_id)
  if user.credits >= 1 do
    MyRepo.update!(%{user | credits: user.credits - 1})
    # ... If above works, create row in another table here. 
  end 
end)

这会将 "lock" 放在用户行吗? (想象一下另一个进程想要在上面的交易中间更新信用)。

Does this put "lock" on user row? (imagine another process wanting to update credits in the middle of transaction above).

没有。这是一个简单的演示:

alias MyApp.{Repo, User}
import Ecto.{Changeset, Query}

user_id = Repo.insert!(%User{credits: 1}).id

pid = spawn(fn ->
  user = Repo.get!(User, user_id)
  caller = receive do caller -> caller end
  Repo.update!(user |> change |> put_change(:credits, -5))
  send(caller, :cont)
end)

Repo.transaction(fn ->
  user = Repo.get!(User, user_id)
  IO.inspect {:before, user.credits}
  if user.credits >= 1 do
    # Ask the other process to update a column.
    send(pid, self)
    # Wait until the other process is done.
    receive do :cont -> :cont end
    IO.inspect {:middle, Repo.get!(User, user_id).credits}
    Repo.update!(user |> change |> put_change(:credits, user.credits - 1))
  end 
  IO.inspect {:after, Repo.get!(User, user.id).credits}
end)

输出:

{:before, 1}
{:middle, -5}
{:after, 0}

解决此问题的一种方法是获取 row-level lock,例如 FOR UPDATE。这将确保在提交或中止当前事务之前不会对同一行进行其他更改。

改变

user = Repo.get!(User, user_id)

user = from(u in User, where: u.id == ^user_id, lock: "FOR UPDATE") |> Repo.one!

在上述代码段中的事务中将(正确地)创建一个死锁,因为另一个进程的 SELECT 不会 return 直到事务完成,并且事务将不会继续直到另一个进程获取用户。

因此,总而言之,您应该替换:

user = Repo.get(User, user_id)

user = from(u in User, where: u.id == ^user_id, lock: "FOR UPDATE") |> Repo.one!

如果您想在交易完成之前阻止对 returned 用户的更新。

(免责声明:我不是数据库专家,但我相信我上面写的是正确的。如果有人有任何疑问或问题,请发表评论!)