几行烂代码,用错 Transactional,我赔了16万。

开源项目芋道源码解析 [开篇] 文章首发于我的博客:https://blog.liuzijian.com/post/source-code-about-ruoyi-vue-pro.html芋道(又名yudao,ruoyi-vue-pro)是一个基于spring-boot框架的单体Java后端开源项目,拥有基于RBAC模型的组织架构管理、CRM、ERP、商城、代码生成、AI等多个功能模块。封装了多租户、数据权限、工作流、OAuth,邮件、短信、定时任务、日志、链路追踪等多种技术和业务组件。其在GitHub上的地址是:https://gith 阅读详情

点击上方“芋道源码”,选择“设为星标”

管她前浪,还是后浪?

能浪的浪,才是好浪!

每天 10:33 更新文章,每天掉亿点点头发...

源码精品专栏

 

62b930fee3173b20519559b2969fa26e.png


前几天在某平台看到一个技术问题,很有意思啊。

涉及到的两个技术点,大家平时开发使用的也比较多,但是属于一个小细节,深挖下去,还是有点意思的。

来,先带你看一下问题是什么,同时给你解读一下这个问题:

https://segmentfault.com/q/1010000040361592

首先,这位同学给出了一个代码片段:

a33dfdf0fd1b3de69bae9d52b4843aa5.png

他说他有一个 func 方法,这个方法里面干了两件事:

  • 1.先查询数据库里面的商品库存。

  • 2.如果还有库存,那么对库存进行减一操作,模拟商品卖出。

对于第二件事,提问的同学其实写了两个操作在里面,所以我再细分一下:

  • 2.1 对库存进行减一操作。

  • 2.2 在订单表插入订单数据。

很显然,这两个操作都会对数据库进行操作,且应该是应该原子性的操作。

所以,在方法上加了一个 @Transactional 注解。

接着,为了解决并发访问的问题,他用 lock 把整个代码包裹了起来,保证在单体结构下,同一时刻只有一个请求能去执行减少库存,生成订单的操作。

非常的完美。

首先,先把大前提申明一下:MySQL 数据库的隔离机制使用的是可重复读级别。

cc0321f6d276f71b729aec9514df176e.png

这个时候,问题就来了。

如果是高并发的情况下,假设真的就有多个线程同时调用 func 方法。

要保证一定不能出现超卖的情况,那么就需要事务的开启与提交能完整的包裹在 lock 与 unlock之间。

显然事务的开启一定是在 lock 之后的。

故关键在于事务的提交是否一定在 unlock 之前?

如果事务的提交在 unlock 之前,没有问题。

因为事务已经提交了,代表库存一定减下来了,而这个时候锁还没释放,所以,其他线程也进不来。

画个简单的示意图如下:

99efb656cd7bb3aea17f5a30acc0b706.png

等 unlock 之后,再进来一个线程,执行查询数据库的操作,那么查询到的值一定是减去库存之后的值。

但是,如果事务的提交是在 unlock 之后,那么有意思的事情就出现了,你很有可能发生超卖的情况。

上面的图就变成了这样的了,注意最后两个步骤调换了:

44d93043728301f659bd68b122f067d6.png

举个例子。

假设现在库存就只有一个了。

这个时候 A,B 两个线程来请求下单。

A 请求先拿到锁,然后查询出库存为一,可以下单,走了下单流程,把库存减为 0 了。

但是由于 A 先执行了 unlock 操作,释放了锁。

B 线程看到后马上就冲过来拿到了锁,并执行了查询库存的操作。

注意了,这个时候 A 线程还没来得及提交事务,所以 B 读取到的库存还是 1,如果程序没有做好控制,也走了下单流程。

哦豁,超卖了。

所以,再次重申问题:

在上面的示例代码的情况下,如果事务的提交在 unlock 之前,是没有问题的。但是如果在 unlock 之后是会有问题的。

那么事务的提交到底是在 unlock 之前还是之后呢?

这个事情,先把问题听懂了,接着我们先按下不表。你可以简单的思考一下。

569729fe810c15f10a081172032f8735.png

我想先聊聊这句被我轻描淡写,一笔带过,你大概率没有注意到的话:

显然事务的开启一定是在 lock 之后的。

这句话,不是我说的,是提问的同学说的:

de04ea213b9324dfb93e7e59f06fc284.png

你有没有一丝丝疑问?

怎么就显然了?哪里就显然了?为什么不是一进入方法就开启事务了?

请给我证据。

来吧,瞅一眼证据。

a330f4b0b87ddba7d3a54b3fd2a2f320.png

事务开启时机

证据,我们需要去源码里面找。

另外,我不得不多说一句 Spring 在事务这块的源码写的非常的清晰易懂,看起来基本上没有什么障碍。

所以如果你不知道怎么去啃源码,那么事务这块源码,也许是你撕开源码的一个口子。

好了,不多说了,去找答案。

答案就藏在这个方法里面的:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

3f05c2fb14aa64a61b934d295d0b7cfd.png

先看我下面框起来的那一行日志:

Switching JDBC Connection [HikariProxyConnection@946359486 wrapping com.mysql.jdbc.JDBC4Connection@7a24806] to manual commit

你知道的,我是个技术博主,偶尔教点单词。

Switching,转换。

Connection,链接。

manual commit,手动提交。

Switching ... to ...,把什么转换为什么。

没想到吧,这次学技术的同时不仅学了几个单词,还会了一个语法。

c3321f4e61371e857ebd43ca9e646cda.png

所以,上面那句话翻译过来就非常简单了:

把数据库连接切换为手动提交。

然后,我们看一下打印这行日志的代码逻辑,也就是被框起来的代码部分。

我单独拿出来:

2cb2e8135ccc43638a050b2a05a848e5.png

逻辑非常清晰,就是把连接的 AutoCommit 参数从 ture 修改为 false。

那么现在问题就来了,这个时候,事务启动了吗?

我觉得没启动,只是就绪了而已。

启动和就绪还是有一点点差异的,就绪是启动之前的步骤。

那么事务的启动有哪些方式呢?

  • 第一种:使用启动事务的语句,这种是显式的启动事务。比如 begin 或 start transaction 语句。与之配套的提交语句是 commit,回滚语句是 rollback。

  • 第二种:autocommit 的值默认是 1,含义是事务的自动提交是开启的。如果我们执行 set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。

很显然,在 Spring 里面采用的是第二种方式。

而上面的代码 con.setAutoCommit(false) 只是把这个链接的自动提交关掉。

事务真正启动的时机是什么时候呢?

前面说的 begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才算是真正启动。

如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。需要注意的是这个命令在读已提交的隔离级别(RC)下是没意义的,和直接使用 start transaction 一个效果。

回到在前面的问题:什么时候才会执行第一个 SQL 语句?

就是在 lock 代码之后。

所以,显然事务的开启一定是在 lock 之后的。

这一个简单的“显然”,先给大家铺垫一下。

接下来,给大家上个动图看一眼,更加直观。

首先说一下这个 SQL:

select * from information_schema.innodb_trx;

不多解释,你只要知道这是查询当前数据库有哪些事务正在执行的语句就行。

你就注意看下面的动图,是不是第 27 行查询语句执行完成之后,查询事务的语句才能查出数据,说明事务这才真正的开启:

7e17240b7de461b7fa5cd20d00f78641.gif

最后,我们把目光转移到这个方法的注释上:

f159b8507c2f250637490492232d7772.png

写这么长一段注释,意思就是给你说,这个参数我们默认是 ture,原因就是在某些 JDBC 的驱动中,切换为自动提交是一个很重的操作。

那么在哪设置的为 true 呢?

没看到代码,我一般是不死心的。

所以,一起去看一眼。

setAutoCommit 这个方法有好几个实现类,我也不知道具体会走哪一个:

442294ab691e230696584abdfa07c802.png

所以,我们可以在下面这个接口打上一个断点:

java.sql.Connection#setAutoCommit

56ddd7fd428d94305f177420fb64e560.png

然后重启程序,IDE 会自动帮你判断走那个实现类的:

d600aebee00290bfbc23fd1b9e0ea036.png

可以看到,默认确实是 true。

等等,你不会真的以为我是想让你看这个 true 吧?

我是想让你知道这个调试技巧啊。

不知道有多少个小伙伴曾经问过我:这个接口实现类好多啊,我怎么知道在哪打断点啊?

我说:很简单啊,就在每个实现类的第一行代码打上断点就好了。

然后他说:别闹,我经常给你的文章一键三联。

我当时就被感动了,既然是这样的好读者,我当然把可以直接在接口上打断点的这个小技巧教给他啦。

50ea02bcf05c1dc356fbdecfb4394f2c.png

好了,不扯远了。

再说一个小细节,这一小节就收尾。

你再去看这小节的开头,我直接说答案藏在这个方法里面:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

直接把答案告诉你了,隐去了探索的过程。

但是这个东西,就像是数学公式推导一样,省略了一步,就会让人看起来一脸懵逼。

就像下面这个小耗子一样:

7e8a6a2b539def73a39658b69cba5951.png

所以,我是怎么知道在这个地方打断点的呢?

答案就是调用栈。

先给大家看一下我的代码:

c4a01d67940c63008ecfdaca3ac9e5fd.png

啥也先不管,上来就先在 26 行,方法入口处打上断点,跑起来:

c5afd3b1fef608809bd4c4ca2946283b.png

诶,你看这个调用栈,我框起来的这个地方:

5dfe9a7a7f4e8db4ea69cf228c848b76.png

看这个名字,你就不好奇吗?

它简直就是在跳着脚,在喊你:点我,快,愣着干啥,你TM快点我啊。我这里有秘密!

然后,我就这样轻轻的一点,就到了这里:

org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction

这里有个切面,可以理解为 try 里面就是在执行我们的业务代码逻辑:

10e85392c155f8f961630e51233f4dc7.png

而在 try 代码块,执行我们的业务代码之前,有这样的一行代码:

366c2564e84c5da6e1edc47c26f6be03.png

找到这里了,你就在这一行代码之前,再轻轻的打个断点,然后调试进去,就能找到这一小节开始的时候,说的这个方法:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

不信?你看嘛,我不骗你。

它们之间只隔了三个调用:

f85913f0a40025df601b06753f2f217d.png

这样就找到答案了。

调用栈,另一个调试源码小技巧,屡试不爽,送给你。

推荐下自己做的 Spring Boot 的实战项目:

https://github.com/YunaiV/ruoyi-vue-pro

之前还是之后

好了,前面是开胃菜,可能有的同学吃开胃菜就已经弄饱了。

没事,现在上正餐,再按一按还是能吃进去的。

3815a2fd52574c27410407196f6e886d.gif

还是拿前面的这份代码来说事,流程就是这样的:

9fe8736818e593ed4ee6dd5c82a6dda2.png
  • 1.先拿锁。

  • 2.查询库存。

  • 3.判断是否还有库存。

  • 4.有库存则执行减库存,创建订单的逻辑。

  • 5.没有库存则返回。

  • 6.释放锁。

所以代码是这样的:

d5932a67ac73b763df108a7f874b1add.png

完全符合我们之前的那份代码片段,有事务,也有锁:

2b97d0251d6b512b555ebdd85529044e.png

回到我们最开始抛出来的问题:

在上面的示例代码的情况下那么事务的提交到底是在 unlock 之前还是之后呢?

我们可以带入一个具体的场景。

比如我数据库里面有 10 个顶配版的 iPad,原价 1.6w 元一台,现在单价 1w 一个,这个价格够秒杀吧?

7ccb654bca45159333c678c80a0e1d0a.png

反正一共就 10 台,所以,我的数据库里面是这样的,

44483fc53fc2bf23ef051f4a747826c7.png

然后我搞 100 个人来抢东西,不过分吧?

我这里用 CountDownLatch 来模拟一下并发:

743f4818990f3c6b3cc868801f406625.png

执行一下,先看结果,立马就见分晓:

4d3c47c006ee659562f366ed22bda792.gif

动图右边的部分:

上面是浏览器请求,触发 Controller 的代码。

然后中间是产品表,有 10 个库存。

最下面是订单表,没有一条数据。

触发了代码之后,库存为 0 了,没有问题。

但是,订单居然有 20 笔!

也就是说超卖了 10 个ipad pro 顶配版!

超卖的,可不在活动预算范围内啊!

那可就是一个 1.6w 啊,10 个就是 16w 啊。

就这么其貌不扬,人畜无害,甚至看起来猥猥琐琐的代码,居然让我亏了整整 16w 。

6fb763309854fd6c694884623808da02.png

其实,结果出现了,答案也就随之而来了。

在上面的示例代码的情况下,事务的提交在 unlock 之后。

94ac6e21f73937fd264f86ac20602f57.png

其实你仔细分析后,猜也能猜出来,肯定是在 unlock 之后的。

而且上面的描述“unlock之后”其实是有一定的迷惑性的,因为释放锁是一个比较特别的操作。

换一个描述,就比较好理解了:

在上面的示例代码的情况下,事务的提交在方法运行结束之后。

你细品,这个描述是不是迷惑性就没有那么强了,甚至你还会恍然大悟:这不是常识吗?

1816e93190ee960e13adce2964873a5e.png

为什么是方法结束之后,分析具体原因之前,我想先简单分析一下这样的代码写出来的原因。

我猜可能是这样的。

最开始的代码结构是这样:

81ad06e92be69820d1292fa08701a380.png

然后,写着写着发现不对,并发的场景下,库存是一个共享的资源,这玩意得加锁啊。

于是搞了这出:

eb21251dd92510af695474ce4dbc376a.png

后面再次审查代码的时候,发现:哟,这个第三步得是一个事务操作才行呀。

于是代码就成了这样:

dc4cd1b90f736b51e5256755ad069762.png

演进路线非常合理,最终的代码看起来也简直毫无破绽。

但是问题到底出在哪里了呢?

851fdb31b1c3c2613f2ebb33171b0e8f.png

推荐下自己做的 Spring Cloud 的实战项目:

https://github.com/YunaiV/onemall

找答案

答案还是在这个类里面:

org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction

391b7500b4f7840b4ac4021852623ac8.png

前面我们聊事务开启的时候,说的是第 382 行代码。

然后 try 代码块里面执行的是我们的业务代码。

现在,我们要研究事务的提交了,所以主要看我框起来的地方。

首先 catch 代码块里面,392 行,看方法名称已经非常的见名知意了:

completeTransactionAfterThrowing 在抛出异常之后完成事务的提交。

你看我的代码,只是用到了 @Transactional 注解,并没有指定异常。

那么问题就来了:

Spring 管理的事务,默认回滚的异常是什么呢?

如果你不知道答案,就可以带着问题去看源码。

如果你知道答案,但是没有亲眼看到对应的代码,那么也可以去寻找源码。

如果你知道答案,也看过这部分源码,温故而知新。

先说答案:默认回滚的异常是 RuntimeException 或者 Error 。

我只需要在业务代码里面抛出一个 RuntimeException 的子类,比如这样的:

7aa1616d1fcf3356430036c33501ce4b.png

然后在 392 行打上断点,开始调试就完事了:

64aa67325bc3d94f4ea8942e0f31d052.png

只需要往下调试几步,你就能走到这个方法来:

org.springframework.transaction.interceptor.RuleBasedTransactionAttribute#rollbackOn

f358e243b56f419926f39cb2bf5534f8.png

发现这个 winner 对象为空,接着走了这个逻辑:

return super.rollbackOn(ex);

答案就藏着这行代码的背后:

0631b4dc3513070664b2b75a758a83d1.png

如果异常类型是 RuntimeException 或者 Error 的子类,那么就返回 true,即需要回滚,调用 rollback 方法:

956798fd962e4d8c89af09119a870159.png

如果返回为 false,则表示不需要回滚,调用 commit 方法:

cf35f61ca9b81efbe956f36772e4bff5.png

那么怎么让它返回 false 呢?

很简单嘛,这样一搞就好了:

72fc4d2ceacb1ccf3d3fa6c99071ab42.png

框架给你留了口子,你就把它用起来。

当我把代码改成上面那样,然后重新启动项目,再次访问代码。

我们去寻找出现指定异常不回滚的具体的实现逻辑在哪。

其实也在我们刚刚看到的方法里面:

c5f77d2d235e43c6a1edd39c233a86a5.png

你看,这个时候 winner 不为 null 了。它是一个 NoRollbackRuleAttribute 对象了。

所以就走入这行代码,返回 false 了:

return !(winner instanceof NoRollbackRuleAttribute);

于是,就成功走到了 else 分支里面,出了异常也 commit 了,你说神奇不神奇:

6002e3af22cc7fb4810dc0c6d9006c64.png

写到这里的时候,我突然想到了一个骚操作,甚至有可能变成一道沙雕面试题:

b6d5e30a5ac8957e3072856abd99ed52.png

这个操作骚不骚,到底会回滚呢还是不回滚呢?

edb7e962b1041f362d692f94cd54e80c.png

如果你在项目里看到这样的代码肯定是要骂一句傻逼的。

但是面试官就喜欢搞这些阴间的题目。

我想到这个问题的时候,我也不知道答案是什么,但是我知道答案还是在源码里面:

21193ef21823bf70fa3dee1de6eab6a2.png

首先,从结果上可以直观的看到,经过 for 循环之后, winner 是 RollbackRuleAttribute 对象,所以下面的代码返回 true,需要回滚:

return !(winner instanceof NoRollbackRuleAttribute);

问题就变成了 winner 为什么经过 for 循环之后是 RollbackRuleAttribute?

答案需要你自己去调试一下,很容易就明白了,我描述起来比较费劲。

简单一句话:导致 winner 是 RollbackRuleAttribute 的原因,就是因为被循环的这个 list 是先把 RollbackRuleAttribute 对象 add 了进去。

那么为什么 RollbackRuleAttribute 对象先加入到集合呢?

org.springframework.transaction.annotation.SpringTransactionAnnotationParser#parseTransactionAnnotation(org.springframework.core.annotation.AnnotationAttributes)

3c8fb64f91d4cf7ecc8c285267676096.png

别问,问就是因为代码是这样写的。

为什么代码要这样写呢?

我想可能设计这块代码的开发人员觉得 rollbackFor 的优先级比 noRollbackFor 高吧。

再来一个问题:

Spring 源码怎么匹配当前这个异常是需要回滚的?

别想那么复杂,大道至简,直接递归,然后一层层的找父类,对比名称就完事了。

b1551d3a40ac7505edf48a6bb118d554.png

你注意截图里面的注释:

一个是 Found it!

表示找到了,匹配上了,用了感叹号表示很开心。

一个是 If we've gone as far as we can go and haven't found it...

啥意思呢,这个 as far as 在英语里面是一个连词,表示“直到..为止..”的意思。引导的是状语从句,强调的是程度或范围。

所以,上面这句话的意思就是:

如果我们已经走到我们能走的最远的地方,还没匹配上,代码就只能这样写了:

b1015f9d683ceffebc908b52c32661ea.png

异常类,最远的地方就是  Throwable.class。没匹配上,就返回 -1。

好了,通过两个没啥卵用的知识点,顺带学了点实战英语,关于业务代码出了异常回滚还是提交这一块的代码就差不多了。

但是我还是建议大家亲自去 Debug 一下,可太有意思了。

然后我们接着聊正常场景下的提交。

fd7be810632e59543e0b13f587b7f992.png

这个代码块里面,try 我们也聊了,catch 我们也聊了。

就差个 finally 了。

我看网上有的文章说 finally 里面就是 commit 的地方。

错了啊,老弟。

这里只是把数据库连接给重置一下。

方法上已经给你说的很清楚了:

0aeb83a1eec24ce994f6f8d097ec91cf.png

Spring 的事务是基于 ThreadLocal 来做的。在当前的这个事务里面,可能有一些隔离级别、回滚类型、超时时间等等的个性化配置。

不管是这个事务正常返回还是出现异常,只要它完事了,就得给把这些个性化的配置全部恢复到默认配置。

所以,放到了 finally 代码块里面去执行了。

真正的 commit 的地方是这行代码:

d9c2f5bc651baeb5da916fcf4a2802da.png

那么问题又来了:

走到这里来了,事务一定会提交吗?

话可别说的那么绝对,兄弟,看代码:

org.springframework.transaction.support.AbstractPlatformTransactionManager#commit

4be13b14163d51b8cfff73ac58a3da4f.png

在 commit 之前还有两个判断,如果事务被标记为 rollback-only 了,还是得回滚。

而且,你看日志。

我这事务还没提交呢,锁就被释放了?

bc580fbc7751254574010e3533f983e5.png

接着往下看 commit 相关的逻辑,我们就会遇到老朋友:

2827187c789ece61f04e05f5f07cf3bc.png

HikariCP,SpringBoot 2.0 之后的默认连接池,强得一比,在之前的文章里面介绍过。

关于事务的提交,就不大篇幅的介绍了。

给大家指个路:

com.mysql.cj.protocol.a.NativeProtocol#sendQueryString

在这个方法的入口处打上断点:

18c5b5bb7a8d941ab72e2dbeeef0b592.png

然后你会发现很多的 SQL 都会经过这个地方。

所以,为了你顺利调试,你需要在断点上设置一下:

ff0f94c56377feb90f696af9c0b21858.png

这样只有 SQL 语句是 commit 的时候才会停下来。

又一个调试小细节,送给你,不客气。

现在,我们知道原因了,那我现在把代码稍微变一下:

829a660ae07c3683fc791335a5f82fc2.png

把 ReentrantLock 换成了 synchronized。

那你说这个代码还会不会有问题?

ab3c2690d1ef6c93a48a4313aa8ed1c1.png

说没有问题的同学请好好反思一下。

这个地方的原理和前面讲的东西是一模一样的呀,肯定也是有问题的。

这个加锁方式就是错误的。

所以你记住了,以后面试官问你 @Transactional 的时候,你把标准答案先背一遍之后,如果你对锁这块的知识点非常的熟悉,就可以在不经意间说一下结合锁用的时候的异常场景。

别说你写的,就说你 review 代码的时候发现的,深藏功与名。

另外记得扩展一下,现在都是集群服务了,加锁得上分布式锁。

但是原理还这个原理。

既然都聊到分布式锁了,这和面试官又得大战几个回合。

是你主动提起的,把面试官引到了你的主战场,拿几分,不过分吧。

一个面试小技巧,送给你,不客气。

5d837da818746ea0efe9fc55d3e7338c.png

解决方案

现在我们知道问题的原因了。

解决方案其实都呼之欲出了嘛。

正确的使用锁,把整个事务放在锁的工作范围之内:

886ae27eefe1919088a5cbf7cbf1d6d2.png

这样,就可以保证事务的提交一定是在 unlock 之前了。

对不对?

96a8f7648172c3d258df05cb7a2d80c2.gif

说对的同学,今天就先到这里,请回去等通知啊。

别被带到沟里去了呀,朋友。

你仔细想想这个事务会生效吗?

提示到这里还没想明白的同学,赶紧去搜一下事务失效的几种场景。

我这里说一个能正常使用的场景:

f780d9569e215e9121156e137b7353e8.png

只是这种自己注入自己的方式,我觉得很恶心。

如果项目里面出现了这样的代码,一定是代码分层没有做好,项目结构极其混乱。

不推荐。

还可以使用编程式事务的方式去写,自己去控制事务的开启、提交、回滚。

比直接使用 @Transactional 靠谱。

除此之外,还有一个骚一点的解决方案。

其他地方都不动,就只改一下 @Transactional 这个地方:

0d4f2c80baea8eeadb5244bea8bca382.png

把隔离级别串行化,再次跑测试用例,绝对不会出现超卖的情况。

甚至都不需要加锁的逻辑。

你觉得好吗?

b6f0d4bf0551247286fb058aeb8bf469.png

好啥啊?

串行化性能跟不上啊!

这玩意太悲观了,对于同一行的数据,读和写的时候都会进行加锁操作。当读写锁出现冲突的时候,后面来的事务就排队等着。

这个骚操作,知道就行了,别用。

你就当是一个没啥卵用的知识点就行了。

但是,如果你们是一个不追求性能的场景,这个没有卵用的知识点就变成骚操作了。

rollback-only

前面提到了这个 rollback-only,为了更好的行文,所以我一句话就带过了,其实它也是很有故事的,单独拿一节出来简单说一下,给大家模拟一下这个场景。

以后你见到这个异常就会感觉很亲切。

Spring 的事务传播级别默认是 REQUIRED,含义是如果当前没有事务,就新建一个事务,如果上下文中已经有一个事务,则共享这个事务。

直接上代码:

7d339823b60314ecf2fc3b8c83bda5c9.png

这里有 sellProduct、sellProductBiz 两个事务,sellProductBiz 是内层事务,它会抛出了异常。

当执行整个逻辑的时候,会抛出这个异常:

Transaction rolled back because it has been marked as rollback-only

59533455cff320aaaca5cfab4b911f9d.png

根据这个异常的堆栈,可以找到这个地方,在前面出现过:

06509717b9273a8a3f328cdc196ecc12.png

所以,我们只需要分析这个 if 条件为什么满足了,就大概摸清楚脉络了。

if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly())

前面的 shouldCommitOnGlobalRollbackOnly 默认为 false:

dfbcb88bcb966b88739757293bfc0140.png

问题就精简为了:defStatus.isGlobalRollbackOnly() 为什么是true?

为什么?

因为 sellProductBiz 抛出异常后,会调用 completeTransactionAfterThrowing 方法执行回滚逻辑。

肯定是这个方法里面搞事情了啊。

org.springframework.transaction.support.AbstractPlatformTransactionManager#processRollback

dd3220e31fc83c0f3a92fd2950dc266e.png

在这里,把链接的 rollbackOnly 置为了 true。

所以,后面的事务想要 commit 的时候,一检查这个参数,哦豁,回滚吧。

大概就是这样的:

b8b674d90e4b5bee393f2acd9102c439.png

如果这不是你期望的异常,怎么解决呢?

理解了事务的传播机制就简单的一比:

94b7c155848bf4688e7ab3d08de88a6e.png

就这样,跑起来没毛病,互不干扰。

36f1c9521b842b70f88050c910cfa3d0.png

好了,本文的技术部分就到这里啦。



欢迎加入我的知识星球,一起探讨架构,交流源码。加入方式,长按下方二维码噢:

8f82063b6fc944fcb9227f5100a31527.png

已在知识星球更新源码解析如下:

7e6f1b99e3a7ca9a76cf32ec979aa37b.png

b733e08a718e23d6e031bac7a4aa9d0c.png

f441b1fb2b537ddbfedb9f6bcbf18310.png

db1a9130a41c524f8d28af4a67defcce.png

最近更新《芋道 SpringBoot 2.X 入门》系列,已经 101 余篇,覆盖了 MyBatis、Redis、MongoDB、ES、分库分表、读写分离、SpringMVC、Webflux、权限、WebSocket、Dubbo、RabbitMQ、RocketMQ、Kafka、性能测试等等内容。

提供近 3W 行代码的 SpringBoot 示例,以及超 4W 行代码的电商微服务项目。

获取方式:点“在看”,关注公众号并回复 666 领取,更多内容陆续奉上。

文章有帮助的话,在看,转发吧。
谢谢支持哟 (*^__^*)
OpenStack Nova手动部署:从控制平面到计算节点的全链路解析 OpenStack Nova是IaaS层核心计算服务,其本质是一组协同工作的Linux进程构成的分布式控制平面。理解Nova需掌握RPC通信、服务分层(api/scheduler/conductor/compute)与数据流向,而非仅依赖一键脚本。手动部署过程强制暴露底层原理:如nova-conductor隔离数据库访问以提升安全性,nova-scheduler依赖Placement API获取资源提供者(RP)实时状态,而计算节点则深度绑定libvirt、KVM与QEMU。该实践直击云基础设施的认知断层, 阅读详情

相关推荐

AI代理失忆的本质与三段式抗失忆记忆架构

AI代理记忆失效并非模型能力不足,而是分布式系统中状态一致性坍塌的必然结果;其核心原理在于执行流与状态快照的异步脱钩,受CAP约束与时间/语义/存储三重结构性缺陷制约;技术价值体现在将不可靠的‘数据库式记忆’升维为具备局部性、分形性与熵减特性的有机记忆系统;典型应用场景覆盖电商履约、智能投顾、工业巡检等高可靠性AI工作流;本文聚焦‘AI代理失忆’与‘三段式记忆分层’两大关键热词,提供可落地的瞬态-事务-归档分层架构及双签名校验实践。

corg81763的博客 394

几行烂代码,我赔了16万。

你好呀,我是why。 前几天在某平台看到一个技术问题,很有意思啊。 涉及到的两个技术点,大家平时开发使用的也比较多,但是属于一个小细节,深挖下去,还是有点意思的。 来,先带你看一下问题是什么,同时给你解读一下这个问题: https://segmentfault.com/q/1010000040361592 首先,这位同学给出了一个代码片段: 他说他有一个 func 方法,这个方法里面干了两件事: 1.先查询数据库里面的商品库存。2.如果还有库存,那么对库存进行减一操作,模拟商品卖出。 对于第二件事,

qq_27243343的博客 1650

基于芋道源码的rocketmq源码版本再次对核心功能实现进行详细的注解标识,一款低延迟、可靠、可扩展、易用的面向消息的中间件

Apache RocketMQ 是阿里巴巴海量消息业务诞生的一款低延迟、可靠、可扩展、易用的面向消息的中间件。Pub/Sub 和 P2P 消息传递模型,在同一队列中实现可靠的 FIFO 和严格的顺序消息传递,长拉队列模型,也支持推流消费风格,单队列百万消息累积能力,通过各种消息传递协议,如JMS,MQTT等。,分布式高可用部署架构,满足至少一次消息投递语义,用于隔离测试和云隔离群集的 Docker 映像,功能丰富的管理仪表板,用于配置、指标和监控

一个update语句执行要10s,大厂的架构真垃圾!

点击上方“芋道源码”,选择“设为星标”管她前浪,还是后浪?能浪的浪,才是好浪!每天 10:33更新文章,每天掉亿点点头发...源码精品专栏原创 | Java 2021超神之路,很肝~中文详细注释的开源项目RPC 框架 Dubbo 源码解析网络应用框架 Netty 源码解析消息中间件 RocketMQ 源码解析数据库中间件 Sharding-JDBC 和 MyCAT 源码解析作业调度中间件 E...

芋艿V 1252

海豚调度器(dolphinscheduler)再次使用,添加钉钉预警

1,首先我们部署是海豚调度器 1.3.8版本,由于页面只有邮件预警,所以在了解源码的同时,需要添加钉钉预警 2,说明,这一块是同事搞的,不是我搞的,新版本的dolphinscheduler已经有了钉钉模块,只是新版本还没有发布 3,我们现在只是添加简单的钉钉预警,任务成功或者失败会发送邮件+钉钉 4,具体操作 找到最新的源码devhttps://github1s.com/apache/dolphinscheduler/blob/dev/dolphinscheduler-alert/src/ma...

qq_31866793的博客 3084

第三节: 串口通信(用CubeMX学习STM32)

用CubeMX学习STM32, 串口通信学习, 通过轮询方式和中断方式两种方式进行通信演示。 STM32F407zgt6

Kevin_8_Lee的博客 8904

(2)SpringBoot学习——芋道源码

内容来自芋道源码(https://www.iocoder.cn/Spring-Boot/autoconfigure/?self)

猫脸码客的博客 2939

基于深度学习的Person Re-ID(综述)

一. 问题的提出        Person Re-ID 全称是 Person Re-Identification,又称为 行人重检测 or 行人再识别,直观上可以通过两种思路进行比对,一种是 通过 静态图像(still-image)进行特征比对,另一种是通过视频的时序特征(temporal)进行 Video Re-Id。        不管是采用 图像特征比对的方法 还是 结合时序特征比

跟随技术的脚步-linolzhang的专栏 2万+

几行烂代码,用错 Transactional,我赔了16万

点击上方蓝色字体,选择“标星公众号”优质文章,第一时间送达关注公众号后台回复pay或mall获取实战项目资料+视频前几天在某平台看到一个技术问题,很有意思啊。涉及到的两个技术点,大家平时开...

公众号-老炮说Java 347

Agent框架系列:1-低代码智能体平台深度解析Dify、Coze、n8n

Dify、Coze 和 n8n 是三大主流低代码智能体平台,各有侧重。Dify 适合企业级 AI 应用,具备强大的 RAG 和知识库管理能力,技术栈为 Python + React。Coze 由字节推出,主打零代码和快速原型开发,集成 60+ 插件,极适合非技术用户。n8n 以工作流自动化和系统集成为核心,支持 400+ 外部应用,基于 Node.js + Vue,扩展性极强。选择建议:非技术团队选 Coze,AI 应用选 Dify,复杂自动化与集成选 n8n。可根据实际需求混合使用,最大化平台优势。

3085

Windows10下MATLAB2017a安装与破解实战:手把手教你搞定双镜像加载

本文详细介绍了在Windows 10系统下安装和破解MATLAB 2017a的完整步骤,包括双镜像加载、许可证配置及常见问题解决方案。通过手把手教程,帮助用户顺利完成安装并优化性能,特别适合工程计算领域的初学者和专业人士。

weixin_29053383的博客 419

几行烂代码,用错 Transactional,赔了16万...

云南以下文章来源于why技术 ,作者why技术前几天在某平台看到一个技术问题,很有意思啊。涉及到的两个技术点,大家平时开发使用的也比较多,但是属于一个小细节,深挖下去,还是有点意思的。来,先带你看一下问题是什么,同时给你解读一下这个问题:首先,这位同学给出了一个代码片段:他说他有一个 func 方法,这个方法里面干了两件事:1.先查询数据库里面的商品库存。2.如果还有库存,那么对库存进行减一操作,模拟商品卖出。

竹林幽深 172

windows在使用ping 127.0.0.1时出现一般故障的解决方案

本文主要介绍了windows在使用ping 127.0.0.1时出现一般故障的解决方案,希望能对使用windows的同学们有所帮助。 文章目录 1. 问题描述 2. 解决方案

weixin_43178406的博客 7万+

我原以为是个笑话,没想到深挖一下背后还有故事。错怪官方了...

往期热门文章: 1、Lombok!代码“亚健康”元凶? 2、几行烂代码,用错 Transactional,赔了16万... 3、上班摸鱼神器!标星 17k 的安卓手机投屏工具! 4、Zed,有望打败 VS Code 吗? 5、标星 43.1 K!最火跨平台笔记开源项目前几天在网上冲浪的时候,看到一条消息,大概内容就是某地官方发文,通报说有一个招投标的项目,经过解析投标文件的制作 IP 地址,发现...

Java后端技术 287

TriCore架构-基于TC397在SOTA过程中AB-SWAP切换底层原理

TC397的SOTA升级机制通过硬件地址重映射实现,利用MMU将逻辑地址动态映射到不同物理地址。其PFlash支持标准/备用地址映射,通常使用PF0+PF1+PF4空间以便于OTA处理。升级时,新固件自动写入非活动分区(A/B分区切换),由地址转换函数完成逻辑地址到物理地址的转换。系统重启后根据寄存器选择启动区域,实现安全无缝的固件更新。

xiandang8023的博客 490

063、推挽变换器的磁偏问题与解决

本文针对推挽变换器中的磁偏问题展开分析。作者通过实际案例指出,磁偏主要由开关管导通压降差异和驱动信号不对称引起,会导致变压器饱和、MOS管炸毁等问题。文章提供了四种解决方案:采用电流模式控制、添加隔直电容、优化对称驱动设计以及引入磁芯气隙(作为最后手段)。调试时应重点观察原边电流波形对称性、变压器声音和直流分量。作者强调实践经验的重要性,建议预留隔直电容位置,并指出推挽拓扑在500W以上功率场合的局限性。这些基于实战的经验总结为电源工程师提供了有价值的参考。

Jack010169的博客 312

工作 6 年,@Transactional 注解用的一塌糊涂

接手新项目一言难尽,别的不说单单就一个 @Transactional 注解用的一塌糊涂,五花八门的用法,很大部分还失效无法回滚。有意识的在涉及事务相关方法上加@Transactional注解,是个好习惯。不过,很多同学只是下意识地添加这个注解,一旦功能正常运行,很少有人会深入验证异常情况下事务是否能正确回滚。@Transa...

cnzzs的博客 995

VB摄像头监控

该VB模块可对摄像头捕获的画面进行录像等,可在创建的窗口进行控制

R空间机器学习工作流:数据-模型-制图无缝整合

空间机器学习是融合地理信息系统与统计建模的交叉技术,其核心在于处理具有坐标、投影、邻接关系和空间自相关性的观测数据。原理上需解耦空间结构与属性特征,通过空间权重、距离变量或滞后项显式建模空间依赖;技术价值体现在避免重复投影校验、提升跨工具链复现效率,并支持端到端可追溯分析;典型应用场景包括滑坡易发性预测、作物病害预警、城市犯罪率建模等GIS密集型任务。R语言凭借sf、raster、terra等原生空间包,将地理坐标视为基础数据类型,实现从遥感影像加载、空间特征工程、空间交叉验证到出版级制图的全流程闭环——这

dy_f的专栏 490

深度学习工具包 DeepLearningToolbox

深度学习工具包Deprecation notice.-----This toolbox is outdated and no longer maintained.There are much better tools available for deep learning than this toolbox, e.g. [Theano](http://deeplearning.net/software/theano/), [torch](http://torch.ch/) or [tensorflow](http://www.tensorflow.org/)I would suggest you use one of the tools mentioned above rather than use this toolbox.Best, Rasmus.DeepLearnToolbox================A Matlab toolbox for Deep Learning.Deep Learning is a new subfield of machine learning that focuses on learning deep hierarchical models of data.It is inspired by the human brain's apparent deep (layered, hierarchical) architecture.A good overview of the theory of Deep Learning theory is[Learning Deep Architectures for AI](http://www.iro.umontreal.ca/~bengioy/papers/ftml_book.pdf)For a more informal introduction, see the following videos by Geoffrey Hinton and Andrew Ng.* [The Next Generation of Neural Networks](http://www.youtube.com/watch?v=AyzOUbkUf3M) (Hinton, 2007)* [Recent Developments in Deep Learning](http://www.youtube.com/watch?v=VdIURAu1-aU) (Hinton, 2010)* [Unsupervised Feature Learning and Deep Learning](http://www.youtube.com/watch?v=ZmNOAtZIgIk) (Ng, 2011)If you use this toolbox in your research please cite [Prediction as a candidate for learning deep hierarchical models of data](http://www2.imm.dtu.dk/pubdb/views/publication_details.php?id=6284)```@MASTERSTHESIS\{IMM2012-06284, author = "R. B. Palm", title = "Prediction as a candidate for learning deep hierarchical models of data", year = "2012",}```Contact: rasmusbergpalm at gmail dot comDirectories included in the toolbox-----------------------------------`NN/` - A library for Feedforward Backpropagation Neural Networks`CNN/` - A library for Convolutional Neural Networks`DBN/` - A library for Deep Belief Networks`SAE/` - A library for Stacked Auto-Encoders`CAE/` - A library for Convolutional Auto-Encoders`util/` - Utility functions used by the libraries`data/` - Data used by the examples`tests/` - unit tests to verify toolbox is workingFor references on each library check REFS.mdSetup-----1. Download.2. addpath(genpath('DeepLearnToolbox'));Example: Deep Belief Network---------------------```matlabfunction test_example_DBNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit RBM and visualize its weightsrand('state',0)dbn.sizes = [100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);figure; visualize(dbn.rbm{1}.W'); % Visualize the RBM weights%% ex2 train a 100-100 hidden unit DBN and use its weights to initialize a NNrand('state',0)%train dbndbn.sizes = [100 100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);%unfold dbn to nnnn = dbnunfoldtonn(dbn, 10);nn.activation_function = 'sigm';%train nnopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.10, 'Too big error');```Example: Stacked Auto-Encoders---------------------```matlabfunction test_example_SAEload mnist_uint8;train_x = double(train_x)/255;test_x = double(test_x)/255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit SDAE and use it to initialize a FFNN% Setup and train a stacked denoising autoencoder (SDAE)rand('state',0)sae = saesetup([784 100]);sae.ae{1}.activation_function = 'sigm';sae.ae{1}.learningRate = 1;sae.ae{1}.inputZeroMaskedFraction = 0.5;opts.numepochs = 1;opts.batchsize = 100;sae = saetrain(sae, train_x, opts);visualize(sae.ae{1}.W{1}(:,2:end)')% Use the SDAE to initialize a FFNNnn = nnsetup([784 100 10]);nn.activation_function = 'sigm';nn.learningRate = 1;nn.W{1} = sae.ae{1}.W{1};% Train the FFNNopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.16, 'Too big error');```Example: Convolutional Neural Nets---------------------```matlabfunction test_example_CNNload mnist_uint8;train_x = double(reshape(train_x',28,28,60000))/255;test_x = double(reshape(test_x',28,28,10000))/255;train_y = double(train_y');test_y = double(test_y');%% ex1 Train a 6c-2s-12c-2s Convolutional neural network %will run 1 epoch in about 200 second and get around 11% error. %With 100 epochs you'll get around 1.2% errorrand('state',0)cnn.layers = { struct('type', 'i') %input layer struct('type', 'c', 'outputmaps', 6, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %sub sampling layer struct('type', 'c', 'outputmaps', 12, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %subsampling layer};cnn = cnnsetup(cnn, train_x, train_y);opts.alpha = 1;opts.batchsize = 50;opts.numepochs = 1;cnn = cnntrain(cnn, train_x, train_y, opts);[er, bad] = cnntest(cnn, test_x, test_y);%plot mean squared errorfigure; plot(cnn.rL);assert(er<0.12, 'Too big error');```Example: Neural Networks---------------------```matlabfunction test_example_NNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);% normalize[train_x, mu, sigma] = zscore(train_x);test_x = normalize(test_x, mu, sigma);%% ex1 vanilla neural netrand('state',0)nn = nnsetup([784 100 10]);opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samples[nn, L] = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.08, 'Too big error');%% ex2 neural net with L2 weight decayrand('state',0)nn = nnsetup([784 100 10]);nn.weightPenaltyL2 = 1e-4; % L2 weight decayopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex3 neural net with dropoutrand('state',0)nn = nnsetup([784 100 10]);nn.dropoutFraction = 0.5; % Dropout fraction opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex4 neural net with sigmoid activation functionrand('state',0)nn = nnsetup([784 100 10]);nn.activation_function = 'sigm'; % Sigmoid activation functionnn.learningRate = 1; % Sigm require a lower learning rateopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex5 plotting functionalityrand('state',0)nn = nnsetup([784 20 10]);opts.numepochs = 5; % Number of full sweeps through datann.output = 'softmax'; % use softmax outputopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex6 neural net with sigmoid activation and plotting of validation and training error% split training data into training and validation datavx = train_x(1:10000,:);tx = train_x(10001:end,:);vy = train_y(1:10000,:);ty = train_y(10001:end,:);rand('state',0)nn = nnsetup([784 20 10]); nn.output = 'softmax'; % use softmax outputopts.numepochs = 5; % Number of full sweeps through dataopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, tx, ty, opts, vx, vy); % nntrain takes validation set as last two arguments (optionally)[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');```[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/rasmusbergpalm/deeplearntoolbox/trend.png)](https://bitdeli.com/free "Bitdeli Badge")

Probability and Statistics - 4th edition

Probability and Statistics - 4th edition

三方支付接入方案

接三方支付,真正的难点不在「怎么发起支付」,而在「怎么确认它真的成功了」——回调可能丢、可能重、可能伪造、可能乱序。本文拆解支付、回调、轮询、订阅、退款五条链路,重点讲清谁都绕不开的四道防线:验签防伪造、防重放挡旧报文、幂等防重复到账、定时补偿兜住丢失的回调,外加关单竞态、金额精度、汇率这些坑。

fengquan8866的博客 757

visio安装包

Microsoft Office Visio 是一款专业的制图软件。利用经过更新的形状、协作工具和数据链接图表,创建专业级图表以简化复杂的信息。

你见过最垃圾的代码长什么样?(来长长见识)

点击上方“芋道源码”,选择“设为星标”管她前浪,还是后浪?能浪的浪,才是好浪!每天 10:33更新文章,每天掉亿点点头发...源码精品专栏原创 | Java 2021超神之路,很肝~...

芋艿V 1054

再启程,研发应用开发平台

今天不做纯技术的分享,而是说下个人的打算,也就是标题表明的:再启程,从头开始,研发一套应用开发平台,完全开源。 可以说,做开发平台,是我的梦想。近20年的职业生涯,先在软件公司从事软件系统的实施、系统的设计和产品的研发,后进入大型企业做信息化工作,做架构与设计,多年的技术积累与经验沉淀,对技术的执著,对业务的理解,有能力做一套优秀的开发平台出来了。

学海无涯,行者无疆 3457

芋道源码 yudao-cloud:构建全功能后台管理系统的技术探索

芋道,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。master分支为 JDK 8 + Spring Boot 2.7,分支为 JDK 17/21 + Spring Boot 3.2管理后台的电脑端:Vue3 提供 element-plus、vben(ant-design-vue) 两个版本,Vue2 提供 element-ui 版本管理后台的移动端:采用 uni-app 方案,一份代码多终端适配,同时支持 APP、小程序、H5!

chenchuang0128的博客 1万+
上一篇: MySQL 跨库分页/ 分表分页/ 跨库分页,为什么这么难?
下一篇: 10:00面试,10:08被送走,问的实在是太...
公众号-芋道源码
博客等级 码龄10年 1631粉丝 377原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值