️This article has been over 2 years since the last update.
很多Java码农(包括我)第一步使用事务就是无脑上@Transaction
注解,但是这样可能就不明不白地用20年,本文是对常见事务问题的QA解答。
什么是Transaction
事务什么时候开始?
准确地说,这取决于JDBC Driver的实现(implementation-defined),因为JDBC的Connection中并没有startTransaction这种显示打开事务的接口。但是在JDBC规格中是这样描述的
The way to allow two or more statements to be grouped into a transaction is to disable the auto-commit mode.
也就是这样开始的
1 | // 一般从连接池中获取 |
同时,我们也得到了事务的定义:a group of statements.
上述命令等价于在SQL中执行
1 | -- 其中第一句在Java中是不用写的,否则会报错 |
Spring/MyBatis的实现
在Spring项目中,它通过@Transaction
注解+Cglib实现了一层AOP的拦截器,@Transaction
注解可以看作原生JDBC层Connection的参数管理,大致上是如下几步
- 通过
@Transaction
实现try/final的样板代码自动执行 - 通过
ThreadLocal
将原生JDBC Connection传递给MyBatais使用 - MyBatis内部调用时引用的是同一个事务
1 | SqlSessionInterceptor -> ThreadLocal -> MyBatis |
其实相比于注解"节约"了代码,但是抛异常流程又反而增加了代码量。我个人更加喜欢使用手写txTemplate的方法,两种总量代码上并不会有太大区别。
能否多次Commit?
在JBDC数据库中是不允许的,必须是start与commit如同栈一样配对使用的。在ORM框架,为了实现傻瓜操作,比如MyBatis中(DefaultSqlSession)使用了一个dirty
的flag来维护状态实现了在Java层避免重复Commit。
存储过程(stored procedures)与事务
(我只知道Oracle的场景)存储过程实际上类似于宏函数
- 默认是无状态的,编译后的SQL与手动运行是一致的,里面不能写begin/commit(更推荐);
- 假如是特殊的
autonomous
存储过程,它类似于fork了一个新的session并维护自己的事务
什么是Dirty read/Dirty data/stale/Eventual consistency?
脏读仅指读取到了某个事务中未commit的数据,它与脏数据没有关系。
- Dirty read: 脏读仅指see data from a transition while that transaction is in progress,它仅仅是数据库领域的术语。这个虽然网上/面试说的很火,但是事实上Oracle/MySQL/PostgreSQL对Read uncommitted级别都不支持,因此网上复杂的流程图没有讨论的意义。
- Dirty data: 含义非常广泛,既可以指数据格式不符合,也可以是业务自己本身的问题,总之是一个很宽泛的词,很难量化与度量,应该避免使用这个词。
- stale: 是缓存领域的术语,翻译“不新鲜/古い”,比如CDN/Redis等场景,当上游刷新后,缓存没有更新,那么数据就不是最新的了,但是这个场景中读取动作不叫做“脏读”,读取的过时数据也不建议称作“脏数据”
- Eventual consistency: 最终一致性,是强一致的对立面,它主要是分布式系统领域的术语,与数据库事务关系不大,适用于对实时性要求不高的场景,比如可视化报表
Select for update
这个是Oracle的行级写入TX(row write exclusive lock),主要用于
- 官方文档中推荐的tree-structured or graph-structured data,比如左边是树形菜单,右边是列表的结构,这种场景一般都是多读少写的场景,直接普通2级事务/直接用Consul等配置中心即可。
- 分布式锁,比如Quartz,这种属于“这个勇者明明超强却过分慎重”,有点大材小用了
比如Quartz防止已经运行的任务重复运行
1 | select * from QRTZ_LOCKS t where t.lock_name='TRIGGER_ACCESS' for update |
注意for update
是先通过where的过滤条件取到数据,再给row加锁,并不是整个表都锁了(MySQL需要加索引,否则锁全表);其它通过for update
获取时,会阻塞等待。这种阻塞等待很容易没释放照成死锁,因此又引入了超时回滚等复杂度,因此这个在真实场景中,使用的人并不多。
我个人在真实场景中,只有一个场景遇到过:执行某个父级任务,该任务下面有多个子任务;当批量任务失败后,子任务也要标记失败。这种parent-child的场景可以使用for update
,但是转念一想,因为每次运行的实例ID都是自增的Seq,在设计上就没有竞争写入场景,所以我又把for update
给删了,毕竟这种SQL很复杂,一旦写完了就成没人敢动的祖传SQL了,最好不要把计算逻辑放到DB里面。
事务隔离级别
网上写的基本很模糊,很多是用例子/特征来描述定义,这样就定义的不准确。我个人建议参考DerBy的文档,它既是简单的数据库可以学习源码,文档也很明确。
RS锁:查询
- Read uncommitted:SELECT不加锁,update/insert/delete加入TX锁。这个级别在Oracle/MySQL等生产数据库中均不支持。
- Read committed:SELECT只在查询遍历的时间端内加入RS锁,update/insert/delete加入TX锁;update冲突时后者会覆盖前者。
- Repeatable read:从开始事务时,SELECT给WHERE中ROW全程加RS锁,update/insert/delete加入TX锁;冲突时后者会覆盖前者。
- Serializable:从开始事务时,SELECT全表全程加RS锁,update/insert/delete加入TX锁;冲突时后者会报错;
分布式事务
对接第三方服务(external APIs)的事务
本文第三方服务的定义: 非自己项目维护,会跨广域网,可能会断网、超时、报错。
假如有如下简化的充话费的场景,其中CRM系统是CURD的Java项目,而计费系统是电信级项目,当CRM与计费中间出现断网/超时/异常的时候,用户就可能白掏钱了,这里的问题就是对接第三方系统的分布式事务问题。
1 | 客户 ---> 运营商CRM ---> 通信计费系统: 充50元话费 |
这里有人可能就要说,我要用A司的X框架或者H司的S框架来实现分布式事务,但是第三方系统基本上就是一个HTTP请求,它是控制不了的。下面给一个单DC下的常见方案,本质上就是在“先扣钱后补偿”与“一次性全跑完”中进行选择。
- 发送给第三方API,第三方返回一个sessionId/订单ID供跟踪使用
- 将SessionId存储到数据库中(网上有称作“本地消息表”的名称,我认为它应该叫做“RCU(Read copy update)”方案),并以此为参数进行轮询或者等待第三方的Webhook
- 在一定时间内(比如5s),通过SessionId查询的结果录入DB,并返回给用户。
- 第三方超时后,仍然扣钱但是告知可能没充成功
- 定制更长时间(取决于第三方)的定时对账任务,一般是基于Webhook with retry strategy,实现退钱
这里主要就是看第三方接口坑不坑了,要是查询一秒就能达到,那么前三步就可以扔到DB里执行,转化为本地事务问题。要是第三方也比较慢,那么大部分场景还是先把用户钱给扣了。
这里再次强调下
- 项目内部不要追求新技术而强行上微服务与分布式事务,这类维护定位培训成本是相当高的
- 超时既不是成功也不是失败,需要具体业务分析
分布式调度器Nomad
Nomad基于dryRun并返回快照ID的方案,真正run时需要传入快照ID,当调度条件发生变化,就无法run任务了
https://www.nomadproject.io/docs/commands/job/plan.html
放弃强一致性: 离线/实时计算/RCU/写时复制/队列
部分场景下,甚至可以说除了转账与库存等场景外,大部分DB的场景都是多读少写的数据,你可以通过ETL工具,倒排索引,或者所谓的【数据中台】来进行二次加工,这类分析计算不需要放到事务中。
代价:
上述提高事务性能的方法主要就是把大事务给拆分为多个小事务,不重要的事务放后面延迟运行,但是数据延迟可能会对后续业务决策场景产生产生放大效应,具体可以参考MIT经典的“啤酒分发游戏”相关理论,以及比特币转账慢带来的问题等扩展阅读。