文档章节

线上账务系统余额并发更新问题记录

hebaodan
 hebaodan
发布于 2017/06/08 14:55
字数 1251
阅读 1090
收藏 40

线上账务系统余额并发更新问题记录

某电商平台,某天线上用户报bug说账户余额信息与交易流水对不上。可以认为是数据库并发更新问题,由此定位出具体原因,并给出解决方案。

问题现象

场景描述

线上账务系统,在定时结算给卖家钱时,且高并发量的情况下,出现提现x元(假设当前用户余额为x元)余额为0后,再转入该账户一笔钱(假设为y元),结果账户余额变为了x+y 元,导致用户余额错误。 ps:账户余额的变更都是在事务中update的

环境说明

mysql5.7 + innodb,事务隔离级别是REPEATABLE-READ

场景模拟

我们简化下线上的数据结构,进行场景模拟。 数据表如下: '账户主表'

CREATE TABLE user (

uid int(11) NOT NULL COMMENT '类型id+自增序列',

name varchar(32) DEFAULT NULL,

PRIMARY KEY (uid)

) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='账户主表'

'账户余额明细表'

CREATE TABLE user_account (

uid int(11) NOT NULL,

amount decimal(19,4) DEFAULT 0 COMMENT '账户余额',

PRIMARY KEY (uid)

) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='账户余额明细表'

账户类型配置

CREATE TABLE user_conf (

type_id int(11) NOT NULL, description varchar(32) DEFAULT NULL COMMENT '类型描述', PRIMARY KEY (type_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='账户类型配置'

具体数据为:

select * from user;

+-------+------+ | uid | name | +-------+------+

| 10001 | a |

| 10002 | b |

select * from user_account;

+-------+----------+ | uid | amount | +-------+----------+

| 10001 | 10.0000 |

| 10002 | 108.9900 |

select * from user_conf;

+---------+--------------+ | type_id | description | +---------+--------------+

| 100 | 外部账户 |

| 200 | 内部账户 |

模拟提现(即余额减)和入账(即余额加)并发操作的事务如下:

session1-提现10元session2-入账20元
begin;
select description from user_conf where type_id = 100;
select * from user where uid = 10001 for update; // user表用来做互斥
select amount from user_account where uid = 10001; // 10.00
begin;
select description from user_conf where type_id = 100;
select * from user where uid = 10001 for update; // wating
//wating
update user_account set amount = 0.00 where uid = 10001;
commit;
拿到锁
select amount from user_account where uid = 10001; //10.00
入账20元,代码中计算后应该为30元
update user_account set amount = 30.00 where uid = 10001;
commit;

问题出现了,后面再查询该用户余额为30元,即用户提现的10元未反映在余额中

原因定位

熟悉mysql的同学或许已经知道问题是由REPEATABLE-READ隔离级别下快照读导致

具体解释:

RR级别下,第一次读操作会生成快照,对于可见性来说,只有当第一次读之前其他事务提交的修改和自己的修改可见,其他的均不可见。

官网文档:https://dev.mysql.com/doc/refman/5.7/en/glossary.html snapshot A representation of data at a particular time, which remains the same even as changes are committed by other transactions.

With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed.

可见性原理

可参考文章:http://hedengcheng.com/?p=148

回到上述模拟场景中,session2在sql语句select description from user_conf where type_id = 100; 时已生成快照,虽然session1提交了,但仍然不可见,导致并发更新问题。

另外,开启事务后,SELECT ... FOR UPDATE 是不会生成快照的,大家可自行实验

解决方案

方案一

将REPEATABLE-READ隔离级别改为READ-COMMITTED,这样即能看到最新提交的数据。

方案二

在读'账户余额明细表'user_account 的时候加 for update,这样会 1.强制读该行记录的最新版本数据,2.且若其他事务未commit,本事务将阻塞,保证串行更新

方案三

延时生成快照。开启事务后,首先就通过user表做互斥,直接for update加锁,针对多个事务并发更新即变为串行。

附:定位过程

  1. 针对上报bug用户,查询其交易流水明细与余额变更明细,确认账务存在问题
  2. 查询账务系统近几天是否有上线变更,检查无
  3. 拉取账务数据库mysql general log,找到并发更新的两个事务session
  4. 查询数据库设置的隔离级别为RR,查询应用数据库连接池配置即session的隔离级别未配置,采用数据库配置
  5. 确认由RR级别导致(当然也可以认为是代码问题导致)
  6. 确认是一个月前账务系统分库分表上线,改用其他连接池且未设置session隔离级别。而之前是有配置session的隔离级别为READ-COMMITTED。

延伸思考

mysql RR级别适用的业务场景是什么,应该怎么选择? 有兴趣或有见解的同学可以留言回复或私信~~

—————分割线 2018.04.27更新———————

大家评论的 update table set column = column +(-) 值 ;也是一种方案

参考

http://blog.csdn.net/chen77716/article/details/6742128#comments

http://hedengcheng.com/?p=148

https://liuzhengyang.github.io/2017/04/18/innodb-mvcc/

© 著作权归作者所有

共有 人打赏支持
hebaodan
粉丝 8
博文 23
码字总数 28604
作品 0
朝阳
程序员
私信 提问
加载中

评论(17)

pain_7
pain_7
我觉得你没有讲明白,而且后面扯到了 user_conf 表,完全没有关系啊
hebaodan
hebaodan

引用来自“逝去的回忆”的评论

兄弟,你好
首先我觉得你上面的表述本身有问题,在REPEATABLE-READ条件下,A事务一旦提交0.00元,B事务怎么可能读取到10.0,这就是一个问题,除非你要表述的是你的客户端代码在并发情况下读取的问题,否则Mysql不可能像你这么读取到10.0,第二个问题是,乐观锁是什么,一个自旋在竞争下能有多好的表现,第三个是 amount = amount+20,利用update的原子操作,该原子操作也是基于行锁等待条件发生的,那基于自身业务还需要读取数据,何必要使用自增的方式去实现,使用条件更新后,利用返回的int值去判定并发的情况,在做下一步处理就结束了,没有你说的这么深奥的。

引用来自“hebaodan”的评论

第一个问题你可以自己试验下,原因是读取数据时前面已经有select语句(这个时候生成了快照),后面即使事务1提交了,事务2是看不到的。
第二个问题所说的乐观锁就是使用条件更新。

引用来自“逝去的回忆”的评论

而且我认为,你把问题复杂化了,先不去考虑快照什么的,这个很基本的mysql默认隔离本身就是这样的,他本身就是不可读的,事务B是没办法读取到的,那么从隔离本身出发去找这个问题就可以了,一个行锁可以解决的问题,就不需要在做其他的,行锁也是最安全可行的做法
没太理解不去考虑快照什么的。mysql默认隔离级别RR就是通过MVCC快照和间隙锁实现的呀。行锁可行,文章方案3是说的行锁呀。
逝去的回忆
逝去的回忆

引用来自“逝去的回忆”的评论

兄弟,你好
首先我觉得你上面的表述本身有问题,在REPEATABLE-READ条件下,A事务一旦提交0.00元,B事务怎么可能读取到10.0,这就是一个问题,除非你要表述的是你的客户端代码在并发情况下读取的问题,否则Mysql不可能像你这么读取到10.0,第二个问题是,乐观锁是什么,一个自旋在竞争下能有多好的表现,第三个是 amount = amount+20,利用update的原子操作,该原子操作也是基于行锁等待条件发生的,那基于自身业务还需要读取数据,何必要使用自增的方式去实现,使用条件更新后,利用返回的int值去判定并发的情况,在做下一步处理就结束了,没有你说的这么深奥的。

引用来自“hebaodan”的评论

第一个问题你可以自己试验下,原因是读取数据时前面已经有select语句(这个时候生成了快照),后面即使事务1提交了,事务2是看不到的。
第二个问题所说的乐观锁就是使用条件更新。
而且我认为,你把问题复杂化了,先不去考虑快照什么的,这个很基本的mysql默认隔离本身就是这样的,他本身就是不可读的,事务B是没办法读取到的,那么从隔离本身出发去找这个问题就可以了,一个行锁可以解决的问题,就不需要在做其他的,行锁也是最安全可行的做法
逝去的回忆
逝去的回忆

引用来自“逝去的回忆”的评论

兄弟,你好
首先我觉得你上面的表述本身有问题,在REPEATABLE-READ条件下,A事务一旦提交0.00元,B事务怎么可能读取到10.0,这就是一个问题,除非你要表述的是你的客户端代码在并发情况下读取的问题,否则Mysql不可能像你这么读取到10.0,第二个问题是,乐观锁是什么,一个自旋在竞争下能有多好的表现,第三个是 amount = amount+20,利用update的原子操作,该原子操作也是基于行锁等待条件发生的,那基于自身业务还需要读取数据,何必要使用自增的方式去实现,使用条件更新后,利用返回的int值去判定并发的情况,在做下一步处理就结束了,没有你说的这么深奥的。

引用来自“hebaodan”的评论

第一个问题你可以自己试验下,原因是读取数据时前面已经有select语句(这个时候生成了快照),后面即使事务1提交了,事务2是看不到的。
第二个问题所说的乐观锁就是使用条件更新。
我之前开发平台,也有类似的问题,但是文章中提出来的几个解决办法,都不是最好的办法,比如乐观锁,如果你使用了乐观锁,在账户生成流水的时候,如何防止幻读的情况,这是么办法去控制的,第二个是乐观锁的性能问题,更重要的是解决办法中更换隔离级别,这在现有平台中几乎无法做到,不可取
hebaodan
hebaodan

引用来自“逝去的回忆”的评论

兄弟,你好
首先我觉得你上面的表述本身有问题,在REPEATABLE-READ条件下,A事务一旦提交0.00元,B事务怎么可能读取到10.0,这就是一个问题,除非你要表述的是你的客户端代码在并发情况下读取的问题,否则Mysql不可能像你这么读取到10.0,第二个问题是,乐观锁是什么,一个自旋在竞争下能有多好的表现,第三个是 amount = amount+20,利用update的原子操作,该原子操作也是基于行锁等待条件发生的,那基于自身业务还需要读取数据,何必要使用自增的方式去实现,使用条件更新后,利用返回的int值去判定并发的情况,在做下一步处理就结束了,没有你说的这么深奥的。
第一个问题你可以自己试验下,原因是读取数据时前面已经有select语句(这个时候生成了快照),后面即使事务1提交了,事务2是看不到的。
第二个问题所说的乐观锁就是使用条件更新。
逝去的回忆
逝去的回忆
兄弟,你好
首先我觉得你上面的表述本身有问题,在REPEATABLE-READ条件下,A事务一旦提交0.00元,B事务怎么可能读取到10.0,这就是一个问题,除非你要表述的是你的客户端代码在并发情况下读取的问题,否则Mysql不可能像你这么读取到10.0,第二个问题是,乐观锁是什么,一个自旋在竞争下能有多好的表现,第三个是 amount = amount+20,利用update的原子操作,该原子操作也是基于行锁等待条件发生的,那基于自身业务还需要读取数据,何必要使用自增的方式去实现,使用条件更新后,利用返回的int值去判定并发的情况,在做下一步处理就结束了,没有你说的这么深奥的。
hebaodan
hebaodan

引用来自“小小宇航员”的评论

引用来自“hebaodan”的评论

引用来自“小小宇航员”的评论

胡诌
请问有什么问题吗?我知道 update user_account set amount = amount-10 where uid = 10001; 也是一种方案呀,利用update的原子操作。文章想要表达的是把以前的场景还原出来分析问题所在~

回复@hebaodan : 发生这个问题和隔离界别PR还是RR没关系,原因是你先查后执行。select都是快照读,你执行是依赖一个旧值就会出问题。PR多了间隙锁,防止幻读的出现。如果你不改变逻辑,只是改隔离级别,这个Bug一直存在。
不知道你说的PR是什么意思。。我觉得你是想表达说用RC还是快照读,解决不了问题,但是,mysql内部是用ReadView机制判断是否能读到新值,可以非常确定的说,RC隔离级别下ReadView会在每次快照读时重新生成,也就是事务1提交事务2就能读到了。对应到文章中的案例,RC情况,session2在拿到锁了之后是能读到新值的,这个bug是能解决的。
小小宇航员
小小宇航员

引用来自“hebaodan”的评论

引用来自“小小宇航员”的评论

胡诌
请问有什么问题吗?我知道 update user_account set amount = amount-10 where uid = 10001; 也是一种方案呀,利用update的原子操作。文章想要表达的是把以前的场景还原出来分析问题所在~

回复@hebaodan : 发生这个问题和隔离界别PR还是RR没关系,原因是你先查后执行。select都是快照读,你执行是依赖一个旧值就会出问题。PR多了间隙锁,防止幻读的出现。如果你不改变逻辑,只是改隔离级别,这个Bug一直存在。
hebaodan
hebaodan

引用来自“小小宇航员”的评论

胡诌
请问有什么问题吗?我知道 update user_account set amount = amount-10 where uid = 10001; 也是一种方案呀,利用update的原子操作。文章想要表达的是把以前的场景还原出来分析问题所在~
小小宇航员
小小宇航员

引用来自“cswy”的评论

update user_account set amount = amount-10 where uid = 10001;
update user_account set amount = amount+20 where uid = 10001;
正解👏
对于账务系统开发的一点儿理解

做支付系统建设也有一段时间了,受累于各种事务性工作,一直没有沉下心好好研究和理解下其中较为核心的部分,账务便是一个。对于开发账务系统的人来说,系统的阅读和学习一些会计知识是必须的...

orpheus
2016/03/07
0
0
运营系统之清结算

1、前言 1.1概述 主要负责支付业务系统的远程商户的清算以及资金结算功能。 1.2术语和定义 1.2.1 清算 清算是指根据资金往来交易的记录,按照一定的规则计算并进行轧差处理的过程。 1.2.2 结...

码代码的小司机
2018/01/15
0
0
金蝶kis记账王账务处理过程中问题汇总

在争分夺秒的现代社会,时间就是生命、金钱和效益,所以摈弃传统繁琐手工记账,代之以使用财务软件实现会计电算化势在必行。另外,使用金蝶财务软件不仅能够提高账务处理效率,更重要的是让财...

石沉大海
2016/11/22
4
0
悲观锁和乐观锁

锁有两种机制:悲观锁和乐观锁。 悲观锁 悲观锁,锁如其名,他对世界是悲观的,他认为别人访问正在改变的数据的概率是很高的,所以从数据开始更改时就将数据锁住,直到更改完成才释放。 一个...

花开半夏qb
2017/09/25
0
0
并发,多线程引起的问题?

业务描述 Rails 服务器(配合 passenger 启在 ubuntu 上)会接受 API,然后直接更新用户余额(可以理解为无脑加钱,因为有加密解密,以及验证) 实现方式 当 api 验证通过后,直接创建一条资...

tonyrisk
2017/08/17
0
0

没有更多内容

加载失败,请刷新页面

加载更多

Android的WIFI局域网对讲机

https://blog.csdn.net/z979451341/article/details/79280749 (三)Android局域网内语音对讲 基于UDP语音传输 https://blog.csdn.net/stormxiaofeng/article/details/80513947 Android7.0手......

shzwork
21分钟前
0
0
vuex

一直有个误区 vuex既然页面刷新会丢失 那还有什么意义 。 重新翻看了下文档才恍然大误,vuex主要解决的是不同组件间的通信。 跨页面数据共享本质上还是用sessionStorage/localStorage...

东东笔记
51分钟前
3
0
网站漏洞检测之WordPress 5.0.0 修复方案

2019年正月刚开始,WordPress最新版本存在远程代码注入获取SHELL漏洞,该网站漏洞影响的版本是wordpress5.0.0,漏洞的产生是因为image模块导致的,因为代码里可以进行获取目录权限,以及文件...

网站安全
今天
3
0
MySql 优化 group by 语句

默认情况下,Mysql 对所有 group by 的字段进行排序,如果查询包括 group by ,用户想要避免排序结果的消耗。可以指定 order by null 禁止排序。 mysql> EXPLAIN select * from sys_log gro...

嘴角轻扬30
今天
15
0
Linux分区&格式化&文件系统&LVM&扩容

硬件 磁盘由 盘片组、主轴马达、机械臂、磁头、驱动芯片和电路、接口等构成 2. 磁盘的分割 每个盘片很多同心圆分割为磁道 Trace 一组盘片的同径磁道叫做一个柱面 Cylinder 每个磁道又被分为很...

可数局部基
今天
6
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部