数据库 并发,多线程引起的问题?

tonyrisk · 2017年08月17日 · 最后由 zjyzxun 回复于 2017年08月24日 · 10927 次阅读

业务描述

Rails 服务器(配合 passenger 启在 ubuntu 上)会接受 API,然后直接更新用户余额(可以理解为无脑加钱,因为有加密解密,以及验证)

实现方式

  1. 当 api 验证通过后,直接创建一条资金记录(MoneyRecord)
  2. 在 after_save 的时候,更新用户身上的余额(就是把所有成功的 MoneyRecord 的钱都加起来)
  3. 在 after_save 的时候用悲观锁,锁住用户(account)对象

业务问题

用户反映,资金记录是没问题,但是余额不正确。比如:

MoneyRecord 1,金额 100,成功后用户余额 100

MoneyRecord 2,金额 200,成功后用户余额 200(这里应该是 300 才对)

贴一下 MoneyRecord 实现代码:

after_save :check_status_change_to_success

def check_status_change_to_success
  if self.status_changed? && self.success?
    self.account.with_lock {
      self.account.reload
      self.account.balance = self.account.money_records.success.sum(:money)
      self.account.save!
    }
  end
end

疑惑

  1. 在开发环境下没重现(我分别用 fork 和 thread 模拟并发创建资金记录,结果都是没问题)
  2. 业务很多其他地方在处理并发的时候,都是直接用悲观锁和 transaction 来处理,其他地方目前都没问题,这里难道有坑?
  3. 是不是因为是 2 个线程,导致线程里的 db 连接也有一定的缓存?

希望各位有经验的能帮忙讨论解决下。

small_fish__ 回复

在外面已经加了 account.with_lock 了

没有实际处理数据库问题的经验,只有纸上谈兵:

self.account.balance = self.account.money_records.success.sum(:money)

这个实现方案,不仅需要锁 account 记录所在的行,还要设法去锁 money_records 表(锁整表或者按查询条件锁多个行,具体要看数据库是否支持细粒度的锁)。再就是还要从数据库读取 money_records 进来,浪费时间。

如果换作是我来实现,会写成这样:

# @money_record 就是正在检查的,是否已完成金额转移的交易
self.account.balance += @money_record.money

看 Rails 的 API 文档实例,也是这么做的:http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html

话又说回到事务处理:transaction 功能并不能智能地摆平一切冲突问题。数据库可以设置成不同的隔离级别(isolation level)。高隔离级别更能保证数据完整性,但可能会牺牲性能。你的数据库具体支持哪些级别,你的应用场合需要哪种级别,这个只能花时间去研究了: https://www.bing.com/search?q=transaction+isolation

隔离级别呢?

matrixbirds 回复

mysql,默认 Repeatable read,有可能是幻读问题

我倾向于的策略是尽量做基于 uuid 的幂等操作,以及基于流水的可复核方案。

已发现问题:这个 transaction 外还有另外一个 transaction,导致【锁】虽然释放了,但是 mr 没有 commit

不要在大项目中乱用 after_save 这种 callback。否则会被坑的很惨。 我们公司现在的做法是给某次操作指定一个 key 然后用 redis 的 SETNX 来判断是否能进行操作。

赞同八楼的观点。我们公司也有类似的业务逻辑。业务复杂了以后,save 引起回调实在难以掌控。现在我们是把逻辑统一提取到 service 中处理。整个过程很清晰,维护起来方便很多。

zjyzxun 回复

请问提交到 service 中处理是啥意思?

happyming9527 回复

“提取”。 在 service 中创建类,用来处理业务逻辑(包括计算)。

hging 回复

是的,只有这个地方用了逻辑的 callback,其他地方基本都是 model 本身得检查或者初始化。否则好乱。另外用 redis 的 setnx 能具体说下是怎么来用吗?

zjyzxun 回复

我们也基本用 service,但是现在遇到 2 个问题:1 是 service 太多了。2 是 service 有可能会嵌套。不知道你们是怎么来用的?

tonyrisk 回复

一个 service 只干一个事情

你可以看下 redis 的 setnx 文档 这个命令可以在 redis 中获取一个状态 当 redis 中没有这个 key 的时候会返回 1 并且可以设置过期时间 有了返回 0 可以根据这个来判断当前是否有并发操作

hging 回复

一个 service 只干一个的话,会巨多 service 文件了。哈哈哈。我们好多业务逻辑。

我比较好奇你们用 setnx 来怎么来进行上面的回调操作。你们用 setnx 相当于是“加锁靠谱”队列是么?把要进行的操作塞到 redis,然后另外一个服务去拿,如果没操作过就执行?不知道流程是不是这样?

mingyuan0715 回复

这个场景比较适合 Redis。

tonyrisk 回复

我们是按照业务类型区分文件夹的。目前有 30 个文件夹。 个人认为,如果把业务划分清楚,放文件夹下,会整齐一些,就好了😀

另外,“service 有可能会嵌套”。我目前还没有遇到过。能否举个栗子,看下呢?

tonyrisk 回复

我觉得 你的这个场景没有必要修改锁的机制。

只是担心,回调会不可控。😂

  1. 这个情境中,反对使用 after_save
  2. 抛开 ruby,来看一下对应的 SQL

begin;
    insert into money_records(account_id, money) values(123, 100); # 1
    select * from accounts where id=123 for update; # 2
    select sum(money) from money_records where account_id=123;  #3
    update  accounts set balance=200 where  id=123; # 4
commit;

因为用了 after_save 所以 insert 语句是在锁 account 之前执行的,应该先锁 account 再去插入 money_record,即 #1 和 #2 顺序颠倒一下。而且#3 也是不需要的,锁住 account 的情况下,直接 blance+money 就够了

LZ fork 是指 Thread or Process?

另外,在后端开发的编程观念里,业务逻辑用多线程是没有意义的(除非异步 IO,先返回后存储,或者除非你写服务器)。

在一般场景中,因为你要 response 客户端,但是你开线程去执行的逻辑可能 request 已经 response 了,而 result 没拿到,结果 response 了没有 result 的结果。

zjyzxun 回复

嵌套:比如有个 service 是购买产品。另外一个 service 是根据条件自动购买产品。后面那个 service 就会去调用前面那个啦

besfan 回复

实际在插入 moneyrecord 的时候在外面 service 已经锁住了 account。不想用简单的加钱操作,因为有加就有减,用重新计算资金记录比单纯加加减减更靠谱和可以幂等。哈哈

zjyzxun 回复

哈哈 哈哈 逮住你了

我觉得这里你只需要在 after_commit 之后进行 mr 的 sum 赋值给 balance 也不需要对 account 加锁

money_record = MoneyRecord.new
money_record.transaction do
  money_record.save!
  account.lock!
  account.balance += money_record.money
  account.save!
end

直接累加就好了,不用每次求和吧

tonyrisk 回复

这样的情况,我觉得并不是问题啊,正常业务如此。你为何觉得这是一个问题呢?

teddyinfi 回复

哈哈哈,大牛你来啦!

这只要把

after_save :check_status_change_to_success

换成

after_commit :check_status_change_to_success, on: :create

就可以了呀!

tumayun 回复

突然注意到这个 if self.status_changed? 这一行 如果放在 after_commit 就失效了,所以用回调来处理业务逻辑就很蛋疼了

还成 after_save 去掉 x lock, 用 redis blpop 弄一个 semaphore 应该是可以的,这个有 gem 的。或者合成一个 sql,直接 update。

tumayun 回复

after_commit中的话,可以用previous_changes来判断,哪个字段改变了。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号