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

tonyrisk · 发布于 2017年08月17日 · 最后由 zjyzxun 回复于 2017年08月24日 · 1158 次阅读
14332

业务描述

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 连接也有一定的缓存?

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

共收到 31 条回复
14332
2973small_fish__ 回复

在外面已经加了 account.with_lock 了

A72675

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

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

17004

隔离级别呢?

14332
17004matrixbirds 回复

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

2329

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

14332

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

11562

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

8134

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

20521
8134zjyzxun 回复

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

8134
20521happyming9527 回复

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

14332
11562hging 回复

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

14332
8134zjyzxun 回复

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

11562
14332tonyrisk 回复

一个service只干一个事情

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

14332
11562hging 回复

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

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

Abca79
2329mingyuan0715 回复

这个场景比较适合 Redis 。

8134
14332tonyrisk 回复

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

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

8134
14332tonyrisk 回复

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

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

7907
  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 就够了

96

LZ fork 是指 Thread or Process?

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

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

14332
8134zjyzxun 回复

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

14332
7907besfan 回复

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

18855
8134zjyzxun 回复

哈哈 哈哈 逮住你了

18855

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

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

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

8134
14332tonyrisk 回复

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

8134
18855teddyinfi 回复

哈哈哈,大牛你来啦!

967

这只要把

after_save :check_status_change_to_success

换成

after_commit :check_status_change_to_success, on: :create

就可以了呀!

18855
967tumayun 回复

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

15317

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

8134
967tumayun 回复

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

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