本文涉及到较多的数据库事务概念,阅读本文前请确保具备必要的相关知识。
事务概述
事务特性
-
Atomicity-原子性:同一个事务的所有操作,要么全部完成,要么全部不完成
-
Consistency-一致性:在事务开始之前和结束之后,数据库的完整性未被破坏(符合预期)
-
Isolation-隔离性:可以防止多个事务交叉执行时导致数据不一致;隔离级别由低到高依次为:
读未提交(Read uncommitted)
读已提交(read committed,RC),Oracle等多数数据库的默认隔离级别
可重复读(repeatable read,RR),Mysql的默认隔离级别
串行化(Serializable)
思考:为什么Mysql的默认隔离级别和其它不一样,是否合理? -
Durability-持久性:事务结束后,对数据的修改就是永久性的
分布式事务
- 事务访问涉及的资源、参与计算的节点部署在不同的节点上
- 大致分两种情况:一个应用多个数据源;多个应用,每个应用涉及不同数据源
- 实现挑战:数据一致性保证,高性能,易用性,业务侵入少
一致性问题
- 多个资源如何保证在任何情况下不会出现数据不一致情况,按时效性分为强一致性和最终一致性
CAP原则
-
Consistency-一致性:更新操作成功后,所有节点在同一时间的数据完全一致
-
Availability-可用性:用户访问数据时,系统是否能在正常响应时间返回结果
-
Partition tolerance-分区容错性:系统在遇到部分节点或网络分区故障的时候,仍然能够提供满足一致性和可用性的服务
当前普遍认为CAP三者只能同时满足其二,而P通常是需要保证的(因为节点故障或网络异常难以避免),Zookeeper选择的是CP(选举期间无法对外提供服务,即不保证A),Eureka选择的是AP(选举期间也可以提供服务,但不保证各节点数据完全一致)
BASE理论
-
Basically Available,基本可用
-
Soft state,软状态/柔性事务
-
Eventual consistency,最终一致性
CAP中一致性和可用性权衡的结果,翻译成人话就是既然强一致性难以做到,那退而求其次,只要最终数据是一致的,中间短暂的不一致通常认为是可以忍受的。
常见解决方案
- 2PC,3PC,TCC,基于本地消息表的最终一致性,基于事务消息的最终一致性,Seata,Saga ……,下面简单介绍几种常见的解决方案。
2PC
- 角色:1个协调者,多个参与者
- 阶段:准备阶段,提交/回滚阶段
- 缺点:第一阶段所有节点同步阻塞,效率低;协调者构成单点故障;提交阶段某节点失败导致不一致
- 实现:基于XA协议的2PC
3PC
- 角色:1个协调者,多个参与者
- 阶段:准备阶段、预提交阶段和提交阶段
- 思想:引入参与者超时机制,解决2PC下协调者和某参与者都挂了之后,新选举的协调者不知道当前应该提交还是回滚的问题
- 缺点:多一个环节,性能稍差;依然存在数据不一致问题
- 实现:基于XA协议的3PC
TCC
- 过程:Try,Confirm/Cancel
- 缺点:代码侵入性大
- 实现:tcc-transaction
基于本地消息表的最终一致性
- 思想:将分布式事务分解为 A系统的本地操作与记录事务消息 + B系统的本地操作,事务发起方再通过轮询消息表+消息中间件通知接收方
- 缺点:引入多个中间件,过程稍复杂
基于事务消息的最终一致性
- 思想:将分布式事务分解为 A系统的本地操作与发送消息 + B系统的本地操作,消息投递成功意味着B的本地事务可以执行成功
- 缺点:消息消费可能失败
- 实现:RocketMQ的事务消息,详见RocketMQ系列(三)——原理篇中的2.5小节
Seata
- 阿里巴巴开源的分布式事务中间件,全称为Simple Extensiable Autonomous Transaction Architecture,简单的、可扩展的、自治的事务架构
- 项目源码:https://github.com/seata/seata
- 项目示例:https://github.com/seata/seata-samples
- 官方说明文档:https://seata.io/zh-cn/docs/overview/what-is-seata.html
事务模式
AT模式
- Automatic Transaction,自动提交事务,基于支持本地ACID事务的关系型数据库,换句话说,如果没有使用到数据库或者数据库不支持事务,则无法使用该模式
- 优点:自动解析与回滚,对业务无侵入;缺点:部分场景无法使用
- 该模式最为常用(毕竟基于事务数据库的场景最多),也是Seata的默认模式,下文描述默认均针对AT模式讲解
TCC模式
- 有时也称为MT(Manual Transaction)模式,手动提交事务,基于TCC,将自定义的分支事务纳入全局事务管理
- 需要自定义业务提交和回滚操作,优缺点与AT模式有点互补
SAGA模式
- 长事务解决方案,适用于业务流程多,耗时长的事务
- 优点:一阶段提交本地事务,无锁,高性能,补偿服务易于实现;缺点:不保证隔离性
- 详情参考官网:https://seata.io/zh-cn/docs/user/saga.html
XA模式
- 基于支持XA事务的数据库,更多详情请参考官网:https://seata.io/zh-cn/docs/dev/mode/xa-mode.html
AT模式架构
TC
- Transaction Coordinator,事务协调器
- 独立部署的服务,维护全局事务的运行状态,接收TM指令发起全局事务提交与回滚,与RM通信协调各个分支事务的提交或回滚
TM
- Transaction Manager,事务管理器
- 应用程序中的工作模块,负责开启全局事务,向TC发起全局提交或回滚的指令
RM
- Resource Manager,资源管理器
- 控制分支事务,负责分支注册、状态汇报,并接收事务协调器TC的指令,驱动分支(本地)事务的提交和回滚
注册中心
- 与所有的注册中心一样,这里的注册中心也记录着服务与服务地址的映射关系,通过它Seata Client(RM、TM)可以发现Seata Server(TC)集群的地址并彼此通信
配置中心
- 存放客户端及服务端所需的各种配置,如注册中心类型、通信编解码方式、心跳检测开关等
- 支持Nacos,Consul,Apollo,Etcd,Zookeeper以及本地文件方式
- 注意:Seata的配置中心与应用程序本身的配置中心是独立和不相关的,当然从技术选型和使用上并无本质区别。
事务原理
基本概念
- 本地事务:应用直连的数据库事务
- 本地锁:数据库本身提供的锁,共享锁(S锁)/排它锁(X锁)
- 全局事务:分布式事务中所有分支事务构成一个全局事务,须满足事务的基本特性
- 全局锁:由TC提供的分布式锁;在全局事务提交前,本地事务可能已经提交,此时需要通过全局锁保证数据的一致性,如禁止修改等
- 脏读:本地事务已提交但全局事务还未提交时,读取到本地已提交的数据,之后全局事务回滚
- 脏写:本地事务已提交但全局事务还未提交时,覆盖了本地已提交的数据,之后全局事务回滚
- undo log日志:事务型数据库存储引擎层面的日志文件
- undo log表:Seata中RM维护在本地数据库中用于分支事务回滚的记录表
- XID:全局事务唯一标识
本地事务
本地事务Demo
@Service
public class StorageService {
@Autowired
private DataSource dataSource;
public void batchUpdate() throws SQLException {
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = dataSource.getConnection();
connection.setAutoCommit(false);
String sql = "update storage_tbl set count = ? where id = ? and commodity_code = ?";
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setInt(1, 100);
preparedStatement.setLong(2, 1);
preparedStatement.setString(3, "2001");
preparedStatement.executeUpdate();
connection.commit();
} catch (Exception e) {
throw e;
} finally {
IOutils.close(preparedStatement);
IOutils.close(connection);
}
}
}
事务核心对象
- DataSource:数据源,负责访问权限认证,连接池管理
- Connection:数据库连接,获取Statement,管理事务开启、提交、回滚以及保存点(savepoint)
主要方法:Connection#prepareStatement,Connection#commit,Connection#setSavepoint,Connection#rollback,setAutoCommit - Statement:执行具体的SQL,但不负责提交
事务核心流程
Seata分布式事务
全局事务Demo
public class BusinessServiceImpl implements BusinessService {
private static final Logger LOGGER = LoggerFactory.getLogger(BusinessService.class);
@Autowired
private StockService stockService;
@Autowired
private OrderService orderService;
@Override
@GlobalTransactional(name = "dubbo-demo-tx") // 该注解表明开启一个全局事务
public void purchase(String userId, String commodityCode, int orderCount) {
LOGGER.info("purchase begin ... xid: " + RootContext.getXID());
stockService.deduct(commodityCode, orderCount);
orderService.create(userId, commodityCode, orderCount);
}
}
分支事务Demo
public class StockServiceImpl implements StockService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public void deduct(String commodityCode, int count) {
jdbcTemplate.update("update stock_tbl set count = count - ? where commodity_code = ?", new Object[]{count, commodityCode});
}
}
OrderService与StockService实现类似,再来看下配置
分支事务配置文件
<-- 正常数据源 -->
<bean name="stockDataSource" class="com.alibaba.druid.pool.DruidDataSource"
init-method="init" destroy-method="close">
<property name="url" value="${jdbc.stock.url}"/>
<property name="username" value="${jdbc.stock.username}"/>
<property name="password" value="${jdbc.stock.password}"/>
</bean>
<-- 代理数据源 -->
<bean id="stockDataSourceProxy" class="io.seata.rm.datasource.DataSourceProxy">
<constructor-arg ref="stockDataSource"/>
</bean>
<-- 注入代理数据源 -->
<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
<property name="dataSource" ref="stockDataSourceProxy"/>
</bean>
事务流程
上面借用一张网络上的总体流程图说明下大致流程,下面是完整详细的事务流程。
流程详解
- TM开启全局事务:全局事务拦截器对业务类进行代理,请求TC开启全局事务
- RM执行本地操作:数据源代理在正常业务SQL执行前后生成镜像并保存到本地undo log表,用于分支事务回滚
- RM注册分支事务并提交:本地SQL事务提交前,向TC注册分支事务
注册成功,提交本地事务,上报本地事务提交状态
注册失败,上报本地事务提交状态 - TM发起全局事务提交或回滚:向TC请求发起全局事务提交或回滚
本地事务提交成功,全局事务提交
本地事务执行或提交失败,全局事务回滚 - TC通知RM提交或回滚
- RM响应TC请求
命令为提交,则RM提交本地事务
命令为回滚,则根据本地的undo log表构造回滚SQL恢复数据,之后清理undo log
上面流程有两个实现关键点,一是构造undo log记录,二是全局加锁
undo log记录
先来看下undo log大概长啥样
undo log记录
{
"branchId": 641789253, // 分支事务id
"undoItems": [{
"afterImage": { // 后镜像
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "GTS"
}, {
"name": "since",
"type": 12,
"value": "2014"
}]
}],
"tableName": "product"
},
"beforeImage": { // 前镜像
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "TXC"
}, {
"name": "since",
"type": 12,
"value": "2014"
}]
}],
"tableName": "product"
},
"sqlType": "UPDATE"
}],
"xid": "xid:xxx" // 全局事务id
}
-
Q1:前后镜像会出现数据不一致问题吗?
-
A:由于本地事务执行时已经加过(本地)锁了,所以sql执行前后的数据是一致的。
-
Q2:回滚时直接恢复到前镜像就可以了,那后镜像的作用是什么?
-
A:保存后镜像的目的是为了在恢复数据时验证这期间数据是否被修改过(未纳入全局事务管理的其它程序或人为修改),可以配置发现不一致时的处理策略。
那么如何防止被其它程序修改呢?
全局锁
- Seata是通过全局锁来保证全局数据一致性的,那么是如何保证的呢?
- 简单来说,就是TC的lock_table表里保存着当前全局事务各分支事务数据库里需要加锁的数据pk,各分支事务在提交时需要先校验本次改动的数据里是否有被全局锁住的数据,如果有冲突则分支事务注册失败,将无法进行提交,从而防止了脏写。
- 脏读情况类似,在读取时同样也会去TC校验。
- 详细原理参考:https://www.cnblogs.com/lay2017/p/12528071.html
全局事务传播 - 分支事务通过XID映射到唯一的全局事务,XID随RPC传递到下游链路(类似于traceId),目前Dubbo实现了对Seata的支持,其它RPC框架可能需要二次开发。
使用限制
- 事务型数据库
- 数据源DML都需要接入
- 不支持 SQL 嵌套
- 不支持多表复杂 SQL
- 不支持存储过程、触发器
- 不支持批量更新 SQL
- 支持范围:https://seata.io/zh-cn/docs/user/sqlreference/dml.html
设计总结
- 四种模式:AT模式,最常用,基于事务型数据库,侵入性小;MT模式,基于TCC,侵入性大;SAGA模式,长事务解决方案;XA模式,基于支持XA事务的数据库
- 通过TC与XID控制全局事务提交或回滚
- 通过生成业务类代理实现业务最小侵入
- 通过undo log记录实现分支事务回滚
- 通过数据源驱动代理实现回滚日志构建
- 通过全局锁保证数据一致性(防止脏读、脏写)
- 在本地事务执行后提交前加全局锁,保证尽量少的锁冲突
参考资料
- 分布式事务解决方案:https://zhuanlan.zhihu.com/p/183753774
- 注册中心与配置中心:https://www.wenjiangs.com/doc/5mz67qzg
- 分布式事务框架Seata原理解析:https://mp.weixin.qq.com/s/pvMta6KhNnp4nEaTh_0iVQ
- Seata AT模式:https://seata.io/zh-cn/docs/dev/mode/at-mode.html
- Seata事务隔离:https://seata.io/zh-cn/docs/user/appendix/isolation.html
- Mysql XA:https://www.cnblogs.com/duyanming/p/7326960.html
- 全局锁:https://www.cnblogs.com/lay2017/p/12528071.html