关于数据库事务的QA
2020-02-09 / modified at 2022-04-04 / 2.3k words / 8 mins
️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
2
3
4
5
6
7
8
9
10
11
// 一般从连接池中获取
Connection con = getConnection()
con.setAutocommit(false);
try {
// run java code and sql lines
con.prepareStatement("UPDATE XXX");
con.prepareStatement("UPDATE XXX");
getConnection().commit();
} catch (e) {
getConnection().rollback();
}

同时,我们也得到了事务的定义:a group of statements.

上述命令等价于在SQL中执行

1
2
3
4
5
6
-- 其中第一句在Java中是不用写的,否则会报错
start transaction;
UPDATE XXX;
UPDATE XXX;
commit;
-- or rollback;

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
2
3
select * from QRTZ_LOCKS t where t.lock_name='TRIGGER_ACCESS' for update
-- insert or update
commit;

注意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经典的“啤酒分发游戏”相关理论,以及比特币转账慢带来的问题等扩展阅读。