一、Seata介绍
1.1、认识Seata
SpringCloud Alibaba为我们提供了用于处理分布式事务的组件Seata。
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
实际上,就是多了一个中间人来协调所有服务的事务。
1.2、Seata的四种事务模式
Seata支持4种事务模式,官网文档:https://seata.io/zh-cn/docs/overview/what-is-seata.html
AT:本质上就是2PC的升级版,在 AT 模式下,用户只需关心自己的 “业务SQL”
一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
二阶段如果确认提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可,当然如果需要回滚,那么就用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。
TCC:和我们上面讲解的思路是一样的。
XA:同上,但是要求数据库本身支持这种模式才可以。
Saga:用于处理长事务,每个执行者需要实现事务的正向操作和补偿操作:
二、实战:集成Seata实现分布式事务(AT模式)
说明:demo项目其中的部分代码写的比较简陋,我们将重心放在分布式事务上即可!
对于2.1部分的代码我已经进行了打包:在对应gitee或github仓库的指定位置下载即可,可直接复现分布式事务问题!
2.1、本地项目搭建(复现分布式事务问题)
项目介绍
为了能够集成Seata组件来实现分布式事务数据一致性的效果,来构建多个微服务进行远程调用。
本次使用到的分布式组件包含:nacos(注册中心)、feign(远程调用组件)
服务包含:book-service(图书服务)、borrow-service(借阅服务)、user-service(用户服务)。
事务问题描述(目标复现):在borrow-service服务中会在一个service中会执行本地事务,远程调用book-service以及user-service的接口,这两个服务的接口都与数据库有交互操作,在没有使用Seata组件前,若是其中某个服务出现异常,那么之前提交的操作都不能够进行回滚,因为这涉及到多个不同的事务管理器。
看一下数据库表:
db_book:图书表,其中count表示该数的库存数量。对应的是book-service。
db_borrow:借阅表。对应的是borrow-service。
db_user:用户表。对应的是user-service。
Nacos服务创建命名空间
创建命名空间为seata-demo:
book-service服务
引入依赖:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.0</version> </dependency> </dependencies>
1、yaml配置项:application.yaml
server: port: 8083 spring: application: name: book-service datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/seata-demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: 123456 cloud: nacos: # 如果不指定命名空间会默认注册到public里面去 如果没有指定分组 会注册到DEFAULT_GROUP server-addr: localhost:8848 # 指定服务注册地址 username: nacos password: nacos discovery: namespace: 0245d1ab-5611-486e-8444-957bebab6d78 # 若是不指定,默认就是public group: BOOK_GROUP # 若是不指定,默认是DEFAULT_GROUP service: book-service # 默认使用的是spring.application.name,这里可以进行指定 #控制台打印sql(默认不会有打印sql语句) mybatis-plus: mapper-locations: classpath*:/mapperxxx/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
2、Mapper接口以及Mapper配置文件、pojo对象
package com.changlu.seatauserservice.pojo; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; import java.io.Serializable; @Data @EqualsAndHashCode(callSuper = false) @TableName("db_user") public class UserModel implements Serializable { private static final long serialVersionUID = 1L; @TableId("uid") private Integer uid; @TableField("name") private String name; @TableField("age") private Integer age; @TableField("book_count") private Integer bookCount; }
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.changlu.seatabookservcie.mapper.BookMapper"> <!-- 通用查询映射结果 --> <resultMap id="BaseResultMap" type="com.changlu.seatabookservcie.pojo.BookModel"> <id column="id" property="id" /> <result column="name" property="name" /> <result column="count" property="count" /> </resultMap> </mapper>
package com.changlu.seatabookservcie.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.changlu.seatabookservcie.pojo.BookModel; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; /** * <p> * Mapper 接口 * </p> * * @author ChangLu * @since 2022-08-02 */ public interface BookMapper extends BaseMapper<BookModel> { @Select("SELECT count from db_book WHERE id = #{id}") int bookRemain(Integer id); @Update("UPDATE db_book set count = count - 1 where id = #{id} and count > 0") int minusBookRemain(Integer id); }
3、Service接口以及实现类:com.changlu.seatabookservcie.service.BookService
package com.changlu.seatabookservcie.service; import com.baomidou.mybatisplus.extension.service.IService; import com.changlu.seatabookservcie.pojo.BookModel; /** * <p> * 服务类 * </p> * * @author ChangLu * @since 2022-08-02 */ public interface BookService extends IService<BookModel> { int bookRemain(Integer id); int minusBookRemain(Integer id); }
package com.changlu.seatabookservcie.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.changlu.seatabookservcie.mapper.BookMapper; import com.changlu.seatabookservcie.pojo.BookModel; import com.changlu.seatabookservcie.service.BookService; import org.springframework.stereotype.Service; import javax.annotation.Resource; /** * <p> * 服务实现类 * </p> * * @author ChangLu * @since 2022-08-02 */ @Service public class BookServiceImpl extends ServiceImpl<BookMapper, BookModel> implements BookService { @Resource private BookMapper bookMapper; @Override public int bookRemain(Integer id) { return bookMapper.bookRemain(id); } @Override public int minusBookRemain(Integer id) { return bookMapper.minusBookRemain(id); } }
4、控制器,对外暴露两个接口,一个是查询以及一个更改数据:
package com.changlu.seatabookservcie.controller; import com.changlu.seatabookservcie.service.BookService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * <p> * 前端控制器 * </p> * * @author ChangLu * @since 2022-08-02 */ @RestController @RequestMapping("/book") public class BookController { @Autowired private BookService bookService; @GetMapping("/remain/{id}") public int bookRemain(@PathVariable("id") Integer id) { return bookService.bookRemain(id); } @GetMapping("/minus/{id}") public int minusBookRemain(@PathVariable("id") Integer id) { return bookService.minusBookRemain(id); } }
5、启动器开启服务注册以及Mapper扫描:启动器上添加
@MapperScan("com.changlu.seatabookservcie.mapper") @EnableDiscoveryClient
user-service服务
引入的依赖与book-service一致,不再贴出。
1、配置文件:application.yaml
server: port: 8081 spring: application: name: user-service datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/seata-demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: 123456 cloud: nacos: # 如果不指定命名空间会默认注册到public里面去 如果没有指定分组 会注册到DEFAULT_GROUP server-addr: localhost:8848 # 指定服务注册地址 username: nacos password: nacos discovery: namespace: 0245d1ab-5611-486e-8444-957bebab6d78 # 若是不指定,默认就是public group: BOOK_GROUP # 若是不指定,默认是DEFAULT_GROUP service: user-service # 默认使用的是spring.application.name,这里可以进行指定 #控制台打印sql(默认不会有打印sql语句) mybatis-plus: mapper-locations: classpath*:/mapperxxx/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
2、mapper接口以及mapper映射配置文件、pojo类
package com.changlu.seataborrowservice.pojo; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; import java.io.Serializable; /** * <p> * * </p> * * @author ChangLu * @since 2022-08-02 */ @Data @EqualsAndHashCode(callSuper = false) @TableName("db_borrow") public class BorrowModel implements Serializable { private static final long serialVersionUID = 1L; @TableId("user_id") private Integer userId; @TableField("book_id") private Integer bookId; }
package com.changlu.seatauserservice.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.changlu.seatauserservice.pojo.UserModel; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; /** * <p> * Mapper 接口 * </p> * * @author ChangLu * @since 2022-08-02 */ public interface UserMapper extends BaseMapper<UserModel> { @Select("SELECT book_count from db_user WHERE uid = #{uid}") int getUserRemainBook(Integer uid); @Update("UPDATE db_user set book_count = book_count - 1 where uid = #{uid} and book_count > 0") int minusUserBookCount(Integer uid); }
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.changlu.seatauserservice.mapper.UserMapper"> <!-- 通用查询映射结果 --> <resultMap id="BaseResultMap" type="com.changlu.seatauserservice.pojo.UserModel"> <id column="uid" property="uid" /> <result column="name" property="name" /> <result column="age" property="age" /> <result column="book_count" property="bookCount" /> </resultMap> </mapper>
3、service接口:
package com.changlu.seatauserservice.service; import com.baomidou.mybatisplus.extension.service.IService; import com.changlu.seatauserservice.pojo.UserModel; /** * <p> * 服务类 * </p> * * @author ChangLu * @since 2022-08-02 */ public interface UserService extends IService<UserModel> { int getUserRemainBook(Integer uid); int minusUserBookCount(Integer uid); }
package com.changlu.seatauserservice.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.changlu.seatauserservice.mapper.UserMapper; import com.changlu.seatauserservice.pojo.UserModel; import com.changlu.seatauserservice.service.UserService; import org.springframework.stereotype.Service; import javax.annotation.Resource; /** * <p> * 服务实现类 * </p> * * @author ChangLu * @since 2022-08-02 */ @Service public class UserServiceImpl extends ServiceImpl<UserMapper, UserModel> implements UserService { @Resource private UserMapper userMapper; @Override public int getUserRemainBook(Integer uid) { return userMapper.getUserRemainBook(uid); } @Override public int minusUserBookCount(Integer uid) { return userMapper.minusUserBookCount(uid); } }
4、控制器:
package com.changlu.seatauserservice.controller; import com.changlu.seatauserservice.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; /** * <p> * 前端控制器 * </p> * * @author ChangLu * @since 2022-08-02 */ @RestController @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @GetMapping("/remainbook/{uid}") public int getUserRemainBook(@PathVariable("uid")Integer uid) { return userService.getUserRemainBook(uid); } @GetMapping("/minusbook/{uid}") public int minusUserBookCount(@PathVariable("uid")Integer uid) { return userService.minusUserBookCount(uid); } }
5、启动器上添加注解,与book-service一致
@MapperScan("com.changlu.seatauserservice.mapper") @EnableDiscoveryClient
borrow-service服务(分布式事务问题产生见其中service方法)
在borrow-service服务中,还包含有feign组件,该服务会对book-service、user-service服务来进行远程调用,那么本次服务的分布式事务问题也是从这里产生的!
引入依赖:与前面服务一致同样也有nacos注册依赖,唯一多了一个就是feign组件
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
1、配置文件:applicaion.yaml
server: port: 8082 spring: application: name: borrow-service datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/seata-demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: 123456 cloud: nacos: # 如果不指定命名空间会默认注册到public里面去 如果没有指定分组 会注册到DEFAULT_GROUP server-addr: localhost:8848 # 指定服务注册地址 username: nacos password: nacos discovery: namespace: 0245d1ab-5611-486e-8444-957bebab6d78 # 若是不指定,默认就是public group: BOOK_GROUP # 若是不指定,默认是DEFAULT_GROUP service: borrow-service # 默认使用的是spring.application.name,这里可以进行指定 #控制台打印sql(默认不会有打印sql语句) mybatis-plus: mapper-locations: classpath*:/mapperxxx/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
2、mapper接口以及mapper映射文件、pojo类
package com.changlu.seataborrowservice.pojo; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; import java.io.Serializable; /** * @author ChangLu * @since 2022-08-02 */ @Data @EqualsAndHashCode(callSuper = false) @TableName("db_borrow") public class BorrowModel implements Serializable { private static final long serialVersionUID = 1L; @TableId("user_id") private Integer userId; @TableField("book_id") private Integer bookId; }
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.changlu.seataborrowservice.mapper.BorrowMapper"> <!-- 通用查询映射结果 --> <resultMap id="BaseResultMap" type="com.changlu.seataborrowservice.pojo.BorrowModel"> <id column="user_id" property="userId" /> <result column="book_id" property="bookId" /> </resultMap> </mapper>
package com.changlu.seataborrowservice.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.changlu.seataborrowservice.pojo.BorrowModel; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; /** * <p> * Mapper 接口 * </p> * * @author ChangLu * @since 2022-08-02 */ public interface BorrowMapper extends BaseMapper<BorrowModel> { @Select("select * from db_borrow where user_id = #{userId} AND book_id = #{bookId}") BorrowModel getBorrow(@Param("userId")Integer userId, @Param("bookId")Integer bookId); @Insert("insert into db_borrow(user_id, book_id) values(#{userId}, #{bookId})") int addBorrow(@Param("userId")Integer userId, @Param("bookId")Integer bookId); }
3、两个服务的feign接口:
package com.changlu.seataborrowservice.feign; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; /** * @Description: * @Author: changlu * @Date: 7:52 PM */ @FeignClient(value = "user-service") public interface BorrowUserFeign { @GetMapping("/user/remainbook/{uid}") int getUserRemainBook(@PathVariable("uid")Integer uid); @GetMapping("/user/minusbook/{uid}") int minusUserBookCount(@PathVariable("uid")Integer uid); }
package com.changlu.seataborrowservice.feign;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; /** * @Description: * @Author: changlu * @Date: 7:52 PM */ @FeignClient(value = "book-service") public interface BorrowBookFeign { @GetMapping("/book/minus/{id}") int minusBookRemain(@PathVariable("id") Integer id); @GetMapping("/book/remain/{id}") int bookRemain(@PathVariable("id") Integer id); }
4、出现分布式事务问题的service方法:
package com.changlu.seataborrowservice.service; import com.baomidou.mybatisplus.extension.service.IService; import com.changlu.seataborrowservice.pojo.BorrowModel; /** * @author ChangLu * @since 2022-08-02 */ public interface BorrowService extends IService<BorrowModel> { Boolean borrow(Integer uid, Integer bookId); }
package com.changlu.seataborrowservice.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.changlu.seataborrowservice.feign.BorrowBookFeign; import com.changlu.seataborrowservice.feign.BorrowUserFeign; import com.changlu.seataborrowservice.mapper.BorrowMapper; import com.changlu.seataborrowservice.pojo.BorrowModel; import com.changlu.seataborrowservice.service.BorrowService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import javax.annotation.Resource; /** * @author ChangLu * @since 2022-08-02 */ @Service public class BorrowServiceImpl extends ServiceImpl<BorrowMapper, BorrowModel> implements BorrowService { @Resource private BorrowMapper borrowMapper; @Autowired private BorrowBookFeign borrowBookFeign; @Autowired private BorrowUserFeign borrowUserFeign; @Override public Boolean borrow(Integer uid, Integer bookId) { //1、判断图书与用户是否都支持借阅 if (borrowBookFeign.bookRemain(bookId) < 0) { throw new RuntimeException("该图书库存不足,无法借阅!"); } if (borrowUserFeign.getUserRemainBook(uid) < 1){ throw new RuntimeException("该用户借阅图书数量已上限!"); } //2、扣减图书库存数量 if (borrowBookFeign.minusBookRemain(bookId) < 1) { //book-service服务修改数据 throw new RuntimeException("扣减图书数量失败!"); } //3、添加图书用户借阅记录 if (borrowMapper.getBorrow(uid, bookId) != null) { throw new RuntimeException("用户已借阅该图书!"); } if (borrowMapper.addBorrow(uid, bookId) < 1) { //本身服务新增数据 throw new RuntimeException("图书借阅失败!"); } //4、用户自己本身借阅数量-1 if (borrowUserFeign.minusUserBookCount(uid) < 1) { //user-service服务修改数据 throw new RuntimeException("用户借阅书籍数量更新有误!"); } return true; } }
5、控制器:
package com.changlu.seataborrowservice.controller; import com.alibaba.fastjson.JSONObject; import com.changlu.seataborrowservice.service.BorrowService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * <p> * 前端控制器 * </p> * * @author ChangLu * @since 2022-08-02 */ @RestController @RequestMapping("/borrow") public class BorrowController { @Autowired private BorrowService borrowService; @GetMapping("/{uid}/{bookId}") public JSONObject borrow(@PathVariable("uid") Integer uid, @PathVariable("bookId") Integer bookId) { JSONObject object = new JSONObject(); Boolean res = false; try { res = borrowService.borrow(uid, bookId); }catch (Exception ex) { object.put("code", 500); object.put("msg", ex.getMessage()); return object; } if (res) { object.put("code", 200); object.put("msg", "借阅成功!"); }else { object.put("code", 500); object.put("msg", "借阅失败!"); } return object; } }
6、启动器上添加注解来进行服务注册、mapper扫描以及feign包扫描增强
@MapperScan("com.changlu.seataborrowservice.mapper") @EnableDiscoveryClient @EnableFeignClients
问题复现测试
提前准备
ok,此时我们的项目环境搭建已经完成,此时就来启动nacos以及我们的三个服务,来进行接口测试吧!
我们来看下数据库当前的一些数据信息:每本书的库存是3本,借阅记录当前没有,用户借阅次数是3次
开始测试
我们来访问borrow-service接口:http://localhost:8082/borrow/1/2
可以看到借阅成功!此时看一下数据库的信息:
可以看到西游记库存扣减1,借阅记录+1,用户借阅书籍数量-1,没有问题,那么我们此时再次调用找个接口试下:
问题提前指出:再次进行请求前我们来看下在borrow-service中的借阅方法怎么写的,若是我们再次调用上次接口,由于我们已经借阅了该书,那么此时就会在下面x位置报出异常,问题就出现了,那么book-service这里做的-1操作就产生数据不一致问题!
来吧,测试一下:果然不出所料
来看下当前的数据库吧:
可以看到红色横线的部分就是未回滚的book-service服务,如何解决这类问题呢?我们可以集成阿里的Seata组件来进行尝试!