“AI 技术+人才”如何成为企业增长新引擎?戳此了解>>> 了解详情
写点什么

记一次事务并发引起的线上数据 BUG

  • 2020-06-27
  • 本文字数:3958 字

    阅读完需:约 13 分钟

记一次事务并发引起的线上数据BUG

1. 问题现象

二手财务系统在收到用户付款后,会做费用项明细拆分。即按照应收费用明细顺序,依次做金额填充,生成实收费用项明细。


然而,财务最近发生了一起奇怪的拆分,分别收取了金额¥2000 和¥3000 的收入,最终拆分结果是:


费用一:¥2000、费用一:¥2000、费用二:¥1000


并不是预期结果:


费用一:¥2000、费用二:¥3000


这是怎么回事?


在业务实际场景中, 我们假定用户应该支付:


费用一:2000、费用二:3000,一共待支付 5000。


在支付时,可以选择一次性支付 5000,或者分多次支付。


当用户选择一次性付款 5000 时,系统会按照应收明细拆分成两笔收款,分别是¥2000 和¥3000。



当用户分两次完成支付时,系统同样是按照应收明细拆分成了两笔:



可以看出,正常情况下,不论用户怎样进行付款,最后收款都会按照应收明细拆分成一样的结果。


根据问题现象进行反推,我们猜测问题可能是这样产生的:



第二笔收入进来时,并没有按照¥3000 的应收明细进行拆分,而是按照先前已经拆分完的第一笔应收明细进行拆分,再将余额按照第二笔应收明细进行拆分。简单讲,就是收入拆分重复了。

2. 初次探究

经过排查日志,发现对这两笔收入的拆分发生在了两个不同的进程中。我们简单梳理一下每笔收入的拆分逻辑,系统会从数据库中读取“待分配”和“已分配”的明细,来确定该对照着哪笔“待分配明细”进行拆分。



那么会不会是读取的“已分配明细”出错了呢?处理收入¥2000 和¥3000 的时候,都认为自己是最新的收入,然后参照着同一份“待分配明细”去填充。为此我们找到两个进程的的日志去看,大致时间如下:



另外,我们还对比了第二步读取待分配和已分配明细中获得的数据,发现是一样的,很明显这是一个经典的脏读问题。


为了解决并发导致的数据错误,首先想到的方法是加锁,使同一笔订单,在同一时间,只能由一个线程处理。笔者使用 Redis 锁进行限制,并将合同号作为 Redis 锁的 key。至于锁加在哪里,当然是加在拆分的方法之外了。


修改后的逻辑大致是这样的:


3. 进一步探究

给出这个解决方案后,在窗口期就进行了上线修复。本以为事情告一段落了,谁知两周之后,叕出现了拆分重复的情况。


看来之前提出加锁的方案并没有解决问题,这个问题必定隐藏着深层次的原因。前面已经进行了分析,必定是引起的脏读导致的,可是为什么加锁解决不了呢?难道是锁没有生效?


在进一步探究之前,我们先来复习一些概念。

3.1 数据库隔离级别

为了提高效率,数据库使用了多线程对数据进行读写,这也就可能导致数据读写存在问题。问题可以归为三类:


并发问题描述
脏读事务1没有提交数据,事务B就读取未提交的数据并进行处理
不可重复读事务B分两次读取数据,期间事务1提交了一次数据,导致事务B读取的数据不一致
幻读事务B两次读取表格数据,此时事务1进行了增删数据的更新,导致事务B读取的数据条数不一致


为了应对上述数据读写存在的问题,MySQL 设置了 4 种隔离级别,每种隔离级别可以避免不同的问题,如下表所示:


隔离级别脏读不可重复读幻读
读未提交(RU, Read uncommitted)不可避免不可避免不可避免
读已提交(RC, Read committed)可避免不可避免不可避免
可重复读(RR, Repeatable read)可避免可避免不可避免
可串行化(Serializable)可避免可避免可避免


  • 读未提交(RU, Read uncommitted)

  • 是最自由的隔离级别,读和写事务可以自由对数据进行操作。因此也无法避免任何一种问题。

  • 举例来说就是:事务 1 和事务 2 各写各的,各读各的,完全感知不到对方的存在;

  • 读已提交(RC, Read committed)

  • 是为了避免读事务读取到写事务没有提交的数据,可以避免脏读问题。

  • 举例来说就是:事务 1 拿取到数据进行加工处理,还没有提交结果,这时候就禁止事务 B 读取到未加工完成的数据。但是这样做仍不能避免不可重复读问题;

  • 可重复读(RR, Repeatable read)

  • 是 MySQL 默认的隔离级别,可以保证读事务对数据的多次读取的值是一致的。

  • 举例来说就是:数据库针对事务 B 做一个拷贝,这样就算事务 1 进行提交,也不会影响到事务 B 了;

  • 可串行化(Serializable)

  • 是最严格的隔离级别,它要求事务串行进行。可以避免全部并发导致的问题。


选取数据库的隔离级别时,一般需要考虑数据并发安全和效率两方面,取一个均衡的方案。

3.2 事务导致的脏读问题

事实上,通过抓取事务执行的 binlog 日志可以看出(下图所示),拆分两笔收入的事务,其中拆分¥2000 的事务(事务 1)commit 与拆分¥3000 的事务(事务 2)的 begin 是在 20:18:02。



根据现象可以推测:事务 2 读取到了事务 1 没有提交的数据,所以都认为自己是新的收入,导致按照相同的待填充明细将收入进行拆分。


按照常理来说,Redis 锁生效了,应该会锁到第一个事务提交完成。可是从日志上来看,这个锁似乎没有生效。问题一定是出在了这个地方。


通过研究这部分的代码,终于理清了处理逻辑。如下图所示,由于历史的设计原因,这个地方存在两个事务嵌套,事务 2 是事务 1 的子事务,而其中的 Redis 锁加在了父事务和子事务之间。



这种设计是存在问题的。


首先来复习一下基本知识—— Spring 支持的事务传播机制,简单讲就是当两个事务存在包含和被包含关系的时候,事务应该怎样去执行。Spring 支持 7 种传播机制。


传播机制描述
PROPAGATION_REQUIRED如果当前没有事务,就新建一个事务。如果已经存在于一个事务中,加入到这个事务中
PROPAGATION_SUPPORTS支持当前事务,如果当前没有事务,就以非事务方式执行
PROPAGATION_MANDATORY使用当前的事务,如果当前没有事务,就抛出异常
PROPAGATION_REQUIRES_NEW新建事务,如果当前存在事务,把当前事务挂起
PROPAGATION_NOT_SUPPORTED以非事务方式执行,如果当前存在事务,就把当前事务挂起
PROPAGATION_NEVER以非事务方式执行,如果当前存在事务,则抛出异常
PROPAGATION_NESTED如果当前存在事务,则在嵌套事务内执行;如果当前没有事务,则执行与 PROPAGATION_REQUIRED类似的操作


通过分析代码得出,二手财务系统使用的是 Spring 默认传播机制:


PROPAGATION_REQUIRED,这就意味着在收入的拆分逻辑中,事务 2 是不起作用的,也就意味着 Redis 锁是在事务 1 内部生效的。由于 Redis 锁是在事务 1 内部生效的,也就无法起到控制事务的作用。在高并发状态下,就会出现两个事务同时处理数据的情况。


根据 MVCC 原理,我们再来回顾一下开始发现的可重复读的问题:事务 1 开始后,事务 2 就会拷贝一份数据用作 select,而这份数据就有可能是事务 1 未提交的。


对整个问题进行流程分析后,梳理结果如下图。由于没有设置锁,步骤 4 和步骤 6 返回的待分配明细和已分配明细是一模一样的,也就导致两个机器填充的是同一份带填充明细。


4. 处理方案

认识到了问题的本质,给出的修复方案就简单了。对此,我们针对事务和锁的处理,提出了两种解决方法。

4.1 修改事务传播机制

既然内部的事务由于默认的传播机制没有生效,那可以将传播机制改为 PROPAGATION_REQUIRES_NEW,来保证嵌套在内部的事务 2 可以正常执行,能够在 Redis 锁释放之前提交数据。



修改代码如下所示:


@Transactional("transactionManager")public void consume(Long id) throws Exception {    lock.tryLock(LockKeyGenerator.gen("Receivable", item.getContractId()),        new ILockBiz<Object>() {            reconciliationSuccessWithTrans();        }}
@Transactional(transactionManager = "transactionManager", propagation = Propagation.REQUIRES_NEW)public void reconciliationSuccessWithTrans() { //收入拆分逻辑 ...}
复制代码

4.2 修改 Redis 锁生效范围

首先,事务 2 没有生效,可以直接删除;而第一次修复是将 Redis 锁加在了事务的内部,这本身就是会导致脏读的问题,因此将 Redis 锁放到事务的外部即可。


修复后的逻辑如下图所示:



修改代码如下所示:


@Transactional("transactionManager")public void consume(Long id) throws Exception {    lock.tryLock(LockKeyGenerator.gen("Receivable", item.getContractId()),        new ILockBiz<Object>() {            //其他逻辑            ...            reconciliationSuccessWithTrans();        }}public void reconciliationSuccessWithTrans() {    //收入拆分逻辑    ...}
复制代码


当然,要根据具体的业务场景选择解决方案。由于财务系统在两个事务的差集部分仍存在数据的提交,将事务设置成 PROPAGATION_REQUIRES_NEW 的传播机制,虽然可以保证内嵌事务回滚引发外层事务回滚,但是外层事务的回滚不会影响内嵌事务,所以需要评估是否会导致数据不一致。此外通过对比逻辑的修改量,我们最终选择了第二种修复方法。

5. 总结

让我们再次回顾下问题原因和解决过程:


  1. 首次发现问题

  2. 经过初步排查,猜测是由于并发导致

  3. 首次修改问题

  4. 通过加锁来保证各个事务是顺序执行的

  5. 再次出现问题

  6. 通过分析发现导致的原因:

  7. a. Spring 传播机制,导致了内部事务无效,锁仅在外部事务的内部生效,不能控制事务顺序执行;

  8. b. MySQL 默认的隔离级别,导致后起事务读取使用的是前面事务的未提交数据;

  9. 最终解决问题

  10. 方案一:将锁提前,设置在外部事务外面,保证对事务的控制;

  11. 方案二:修改 Spring 事务隔离级别,保证内部事务可以独立生效,同时也可以保证锁的作用


大家平时在对数据库进行写操作时,一定要注意事务的处理,这次问题就是由于历史逻辑设计不合理所导致的。


随着公司业务量的增加,这种高并发的问题会暴露得更多。因此在编程时,我们一定要锻炼出 良好的高并发思维,做到未雨绸缪彻桑土、御冬旨蓄备桃诸


本文转载自公众号贝壳产品技术(ID:beikeTC)。


原文链接


https://mp.weixin.qq.com/s?__biz=MzIyMTg0OTExOQ==&mid=2247485712&idx=3&sn=80cf6470327a02228ae90d5e982f39ba&chksm=e8373a60df40b3769b96c27345847d53d632966fd73396295f8bbea0c299ae0005ac1e363184&scene=27#wechat_redirect


2020-06-27 10:001805

评论

发布
暂无评论
发现更多内容

写给互联网工程师的5G书 | 2. 无线传输

俞凡

架构 5G

JavaScript进阶(三)模块化

Augus

JavaScript 9月日更

产品分析:谁是利益相关者?

石云升

产品经理 9月日更

在线JSON转jsdoc工具

入门小站

工具

Redis核心原理与实践--列表实现原理之quicklist结构

binecy

redis 数据结构 原理分析

CyclicBarrier类在性能测试中应用

FunTester

线程 多线程 性能测试 线程安全 FunTester

vue之keep-alive作用和原理

法医

大前端 9月日更

中秋节如何拍月亮

卢卡多多

9月日更

linux之fping命令

入门小站

Linux

网络攻防学习笔记 Day140

穿过生命散发芬芳

9月日更 网站安全基础

边缘使用 K8s 门槛太高?OpenYurt 这个功能帮你快速搭建集群!

阿里巴巴云原生

阿里云 云原生 边缘计算

【直播预告】阿里云服务网格 ASM 产品易用性改善实践与思考

阿里巴巴云原生

阿里云 云原生

autojs自动化框架简介

IT蜗壳-Tango

9月日更

Node 编码规范 -努力做得更好

Geek_25b8d1

node.js Node 规范

译文:为什么超链接是蓝色的?(一)

姬翔

数据结构与算法:缓存置换算法

正向成长

LRU 置换算法

写给互联网工程师的5G书 | 3. 基础架构

俞凡

架构 5G 网络

模块八作业

NewBranSTONE

架构实战营

JVM 内存模型学习笔记(二)

风翱

JVM 9月日更

【Vuex 源码学习】第六篇 - Vuex 的模块收集

Brave

源码 vuex 9月日更

华强买瓜•程序员版

三分恶

程序员

Prometheus 2.22.0 新特性

耳东@Erdong

Prometheus 9月日更

小小感悟

Nydia

【LeetCode】括号的最大嵌套深度Java题解

Albert

算法 LeetCode 9月日更

k8s deployment controller源码分析

良凯尔

Kubernetes 源码分析 Kubernetes源码 #Kubernetes#

Java“锁”事

中原银行

Java 中原银行

什么是数据粒度

奔向架构师

数据仓库 9月日更

【优化技术专题】「温故而知新」基于Quartz系列的任务调度框架的动态化任务实现分析

洛神灬殇

Java quartz 任务调度 9月日更

【LeetCode】回文链表Java题解

Albert

算法 LeetCode 9月日更

【Flutter 专题】42 图解页面截屏与本地保存小尝试

阿策小和尚

Flutter 小菜 0 基础学习 Flutter Android 小菜鸟 9月日更

Opus从入门到精通(四)Opus解码程序实现

轻口味

android 音视频 9月日更

记一次事务并发引起的线上数据BUG_数据库_张聪_InfoQ精选文章