高并发的情况下如何确保余额不为负数

2018年12月4日 0 作者 筱枫

最近在做一个功能,是涉及到虚拟金币一类的交易,当中有个功能,是将本人的金币转移给另外的人
这很简单,大多数人都会直接写两条sql,一条减去本人的金币,一条加上另外人的金币
例如

update user set gold=gold-10 where user_id = 1;
update user set gold=gold+10 where user_id = 2;

不过聪明的你一定会想到万一后一条语句失败了怎么办
所以会采取事务的方式确保一致性

set autocommit = 0;

begin;

update user set gold=gold-10 where user_id = 1;
update user set gold=gold+10 where user_id = 2;

commit;

ok,现在我们的硬性要求是余额不能为负数,所以会改成这样
(事务方面就不写了)

update user set gold=gold-10 where user_id = 1 where gold >= 10;
update user set gold=gold+10 where user_id = 2;

好的,这样就解决了这个问题

但是,我们采用流水的方式记录数据,最后通过sum函数求和用户的数据,这种情况下稍显复杂,因为语句是这个样子的

# 这里假定已经计算成功用户的余额足够扣除

insert into gold_log (user_id, number) values (1, -10);
insert into gold_log (user_id, number) values (2, 10);

如果像之前一样采取事务的话,因为事务隔离性的问题,可能会导致用户余额被扣成负数
因为之前的update会锁住某一行,这样无论是where还是其他什么的update、delete都无法操作这行数据
而第二个问题不一样,insert语句不会锁行,所以

可能会有两个事务同时用sum函数计算出用户余额足够扣除
然后同时执行了插入操作!

这种情况下,mysql的innodb引擎默认的隔离级别是RR,也就是无法确保这种情况!
这也就是幻读。
这个时候可以选择提高事务的隔离级别确保,或者像刚刚一样在user表中建立字段,然后先加减user表,再记录流水
这里我采用了另外一种办法,也就是类似乐观锁的方式

先假定没有另外的事务会导致用户余额被扣成负数
然后先进行insert,insert完成后在计算一次用户的余额,如果发现为负数则回滚。

这样可以避免Serializable事务隔离模式所带来的高昂代价
但是,凡事有利有弊,这样可能会出现刚刚这种两个事务同时写入,然后同时检测到用户余额为负数,同时回滚的情况
这种情况的正确方式应该是成功一笔,失败一笔才正确

但不论怎么说,这也算是一种解决方式

关于事务隔离,以及update原子性,可以参考以下文章:
讨论 update A set number=number+ ? where id=?的原子性 (含数据库原理)
MySQL 四种事务隔离级的说明
数据库事务原子性、一致性是怎样实现的?