阿里、蚂蚁、晟腾、中科加禾精彩分享 AI 基础设施洞见,现购票可享受 9 折优惠 |AICon 了解详情
写点什么

记一次事务并发引起的线上数据 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:001817

评论

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

react源码解析15.scheduler&Lane

buchila11

React React Hooks

网络协议之:还在用HTTP代理?弱爆了!快试试SOCKS5

程序那些事

网络协议 HTTP 程序那些事 12月日更 SOCKS5

Java 开发中常见的 10 个错误

编程江湖

JAVA开发 java编程

让设计如语言一般自然

鲸品堂

设计 通信 运营商

陈齐彦 Roby:开源也许是中国未来IT行业最重要的统战对象 I OpenTEKr 大话开源 Vol.1

OpenTEKr

大话开源

异构组网如何解决共享资源冲突?|HDC2021技术分论坛

HarmonyOS开发者

HarmonyOS

node服务端渲染

编程江湖

node.js 大前端

Java 动态代理之 InvocationHandler 最简单的入门教程

Jerry Wang

Java 动态代理 28天写作 SAP 12月日更

Hadoop完全分布式安装部署

编程江湖

大数据 hadoop

quarkus微服务+istio+k8s云原生devops持续集成演示( Tekton(ci/cd) + quarkus + harbor + Nexus)

weir威尔

Kubernetes istio 持续集成 Tekton Quarkus

TDS构建数据湖、数据仓库应用介绍

星环科技

全面适配DPDK 20.11,DPVS发布v1.9.0版本

爱奇艺技术产品团队

华为AppCube入选Forrester《中国低代码平台市场分析报告》

华为云开发者联盟

华为 低代码 AppCube 应用魔方 Forrester

java开发SSM框架整合之MyBatis动态SQL

@零度

mybatis ssm JAVA开发

Redis 分布式锁的正确实现原理演化历程与 Redisson 实战总结

码哥字节

redis RedLock redisson 分布式锁 签约计划第二季

大厂算法面试之leetcode精讲22.字典树

全栈潇晨

LeetCode 算法面试

react源码解析16.concurrent模式

buchila11

React React Hooks

python入门难?十之八九是因为python 协程吧!

梦想橡皮擦

12月日更

入驻快讯|欢迎字节跳动终端技术团队正式入驻 InfoQ 写作平台!

InfoQ写作社区官方

入驻快讯

TDengine在雷达台站运维管理系统中的落地实践

TDengine

数据库 tdengine 时序数据库

爱奇艺基础数据平台演进

爱奇艺技术产品团队

大厂算法面试之leetcode精讲21.树

全栈潇晨

算法 LeetCode

深度解读|Spark 中 CodeGen 与向量化技术的研究

Kyligence

说了半天跨平台,今儿咱就来跨跨!(上)

为自己带盐

Docker dotnet 28天写作 签约计划第二季 12月日更

瞰源 | 当我们谈论开源时,我们在谈论什么?

OpenTEKr

狄安瞰源

从源码分析快速实现对新开源软件的检测

华为云开发者联盟

二进制 开源软件 二进制SCA 特征

图数据和知识图谱,数字化转型的新引擎

星环科技

图数据库 知识图谱

给弟弟的信第4封|你需要尽早知道的理财知识

大菠萝

28天写作

Linux系统学习《Linux一学就会》Linux文件系统结构

侠盗安全

Linux linux运维 运维工程师 云计算架构师

前端开发框架react 之UmiJS

@零度

大前端 React

Redis 很强,不懂使用规范就糟蹋了

码哥字节

redis Redis开发规范 签约计划第二季

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