博客 / 詳情

返回

Spring Cloud分佈式事務(基於Seata AT模式,集成Nacos)--學習版

Spring Cloud分佈式事務快速上手(基於Seata AT模式,集成Nacos)--學習版

前言

  對於從未接觸過Seata的同學來説,想要快速上手Seata還是需要花費比較長的時間,因為本身微服務開發中環境的搭建、以及各種配置都已經很繁瑣了,然後再集成Seata,Seata又有許多配置,對於每個微服務來説,針對Seata又有一些配置,要搞清楚各種配置之間的關係,對於像我這樣的小白來説,着實不是一件容易的事。但Seata作為分佈式事務的關鍵解決方案,在微服務架構中起着至關重要的作用。接下來,我將結合自身小白學習踩坑的過程,為大家介紹Seata的實操步驟,幫助大家少走彎路。

依賴的相關環境及組件

  為了方便像我這樣的小白快速上手,我只能踩着巨人的肩膀前進,本文中相關demo基本參考ruoyi-cloud官方文檔中的示例(能參考別人的代碼千萬別自己動手寫),只做了部分改造,方便驗證;同時,微服務環境框架也是直接用的ruoyi-cloud,感謝每一位開源前輩無私無畏的奉獻。

組件 版本
    ruoyi-cloud         v3.6.6    
    Seata         1.4.0    
    Nacos         2.5.0    

部署Seata-server(集成Nacos)

下載Seata-server

  可以從GitHub倉庫https://github.com/apache/incubator-seata/releases下載各版本的Seata-server(直達鏈接:Seata-server各Releases版本)。Windows下載解壓後(.zip),直接點擊bin/seata-server.bat就可以啓動。

配置Seata-server,集成Nacos

  使用Nacos作為註冊中心及配置中心,需要修改Seata目錄下conf/registry.con中的相關配置。由於使用Nacos作為註冊中心,所以conf目錄下的file.conf無需理會。主要修改兩個地方,一個是registry.type改為nacos(默認是file),另一個是config.type改為nacos(默認是file);當然,registry.nacos,和config.nacos中需要修改成自己的環境,例如Nacos地址:127.0.0.1:8848,以及username和password,修改完成後,Seata-server就會使用Nacos作為註冊中心和配置中心。

registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"
  loadBalance = "RandomLoadBalance"
  loadBalanceVirtualNodes = 10

  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "default"
    username = ""
    password = ""
  }
  eureka {
    serviceUrl = "http://localhost:8761/eureka"
    application = "default"
    weight = "1"
  }
  redis {
    serverAddr = "localhost:6379"
    db = 0
    password = ""
    cluster = "default"
    timeout = 0
  }
  zk {
    cluster = "default"
    serverAddr = "127.0.0.1:2181"
    sessionTimeout = 6000
    connectTimeout = 2000
    username = ""
    password = ""
  }
  consul {
    cluster = "default"
    serverAddr = "127.0.0.1:8500"
  }
  etcd3 {
    cluster = "default"
    serverAddr = "http://localhost:2379"
  }
  sofa {
    serverAddr = "127.0.0.1:9603"
    application = "default"
    region = "DEFAULT_ZONE"
    datacenter = "DefaultDataCenter"
    cluster = "default"
    group = "SEATA_GROUP"
    addressWaitTime = "3000"
  }
  file {
    name = "file.conf"
  }
}

config {
  # file、nacos 、apollo、zk、consul、etcd3
  type = "nacos"

  nacos {
    serverAddr = "127.0.0.1:8848"
    namespace = ""
    group = "SEATA_GROUP"
    username = ""
    password = ""
  }
  consul {
    serverAddr = "127.0.0.1:8500"
  }
  apollo {
    appId = "seata-server"
    apolloMeta = "http://192.168.1.204:8801"
    namespace = "application"
    apolloAccesskeySecret = ""
  }
  zk {
    serverAddr = "127.0.0.1:2181"
    sessionTimeout = 6000
    connectTimeout = 2000
    username = ""
    password = ""
  }
  etcd3 {
    serverAddr = "http://localhost:2379"
  }
  file {
    name = "file.conf"
  }
}

上傳配置至Nacos配置中心

  在Nacos中新建配置,dataId為seataServer.properties,group為SEATA_GROUP(與Seata-server中config.nacos.group一致),配置內容參考https://github.com/apache/incubator-seata/tree/develop/script/config-center的config.txt並按需修改保存(直達鏈接:配置中心內容)。若不想手動複製,也可使用該鏈接目錄下/nacos/nacos-config.sh或/nacos/nacos-config.py腳本導入到Nacos,這裏主要修改兩個地方,同時,由於後邊將會搭建三個測試微服,這裏需增加事務分組的配置(事務分組的具體解釋請參考Seata官網https://seata.apache.org/zh-cn/docs/v1.4/user/txgroup/transaction-group):
  如下修改為db,分佈式事務的核心數據存儲至數據庫
  store.mode=db
  store.lock.mode=db
  store.session.mode=db
  以及配置數據庫連接信息
  store.db.driverClassName
  store.db.url
  store.db.user
  store.db.password
  增加事務分組配置,每個服務一個,一般都採用將key 值設置為服務端的服務名,有多少個微服務就添加多少行。
  #後邊會搭建賬户、商品、訂單三個測試微服務,這配置每個微服務的事務分組
  service.vgroupMapping.ruoyi-account-group=default
  service.vgroupMapping.ruoyi-order-group=default
  service.vgroupMapping.ruoyi-product-group=default

#事務分組配置
service.vgroupMapping.ruoyi-account-group=default
service.vgroupMapping.ruoyi-order-group=default
service.vgroupMapping.ruoyi-product-group=default

#For details about configuration items, see https://seata.io/zh-cn/docs/user/configurations.html
#Transport configuration, for client and server
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.enableTmClientBatchSendRequest=false
transport.enableRmClientBatchSendRequest=true
transport.enableTcServerBatchSendResponse=false
transport.rpcRmRequestTimeout=30000
transport.rpcTmRequestTimeout=30000
transport.rpcTcRequestTimeout=30000
transport.threadFactory.bossThreadPrefix=NettyBoss
transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker
transport.threadFactory.serverExecutorThreadPrefix=NettyServerBizHandler
transport.threadFactory.shareBossWorker=false
transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector
transport.threadFactory.clientSelectorThreadSize=1
transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread
transport.threadFactory.bossThreadSize=1
transport.threadFactory.workerThreadSize=default
transport.shutdown.wait=3
transport.serialization=seata
transport.compressor=none

#Transaction routing rules configuration, only for the client
service.vgroupMapping.default_tx_group=default
#If you use a registry, you can ignore it
service.default.grouplist=127.0.0.1:8091
service.enableDegrade=false
service.disableGlobalTransaction=false

#Transaction rule configuration, only for the client
client.rm.asyncCommitBufferLimit=10000
client.rm.lock.retryInterval=10
client.rm.lock.retryTimes=30
client.rm.lock.retryPolicyBranchRollbackOnConflict=true
client.rm.reportRetryCount=5
client.rm.tableMetaCheckEnable=true
client.rm.tableMetaCheckerInterval=60000
client.rm.sqlParserType=druid
client.rm.reportSuccessEnable=false
client.rm.sagaBranchRegisterEnable=false
client.rm.sagaJsonParser=fastjson
client.rm.tccActionInterceptorOrder=-2147482648
client.tm.commitRetryCount=5
client.tm.rollbackRetryCount=5
client.tm.defaultGlobalTransactionTimeout=60000
client.tm.degradeCheck=false
client.tm.degradeCheckAllowTimes=10
client.tm.degradeCheckPeriod=2000
client.tm.interceptorOrder=-2147482648
client.undo.dataValidation=true
client.undo.logSerialization=jackson
client.undo.onlyCareUpdateColumns=true
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
client.undo.logTable=undo_log
client.undo.compress.enable=true
client.undo.compress.type=zip
client.undo.compress.threshold=64k
#For TCC transaction mode
tcc.fence.logTableName=tcc_fence_log
tcc.fence.cleanPeriod=1h

#Log rule configuration, for client and server
log.exceptionRate=100

#Transaction storage configuration, only for the server. The file, db, and redis configuration values are optional.
# 修改為db,分佈式事務的核心數據存儲至數據庫
store.mode=db
store.lock.mode=db
store.session.mode=db
#Used for password encryption
store.publicKey=

#If `store.mode,store.lock.mode,store.session.mode` are not equal to `file`, you can remove the configuration block.
store.file.dir=file_store/data
store.file.maxBranchSessionSize=16384
store.file.maxGlobalSessionSize=512
store.file.fileWriteBufferCacheSize=16384
store.file.flushDiskMode=async
store.file.sessionReloadReadSize=100

#These configurations are required if the `store mode` is `db`. If `store.mode,store.lock.mode,store.session.mode` are not equal to `db`, you can remove the configuration block.
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver
# 配置數據庫連接信息
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=username
store.db.password=password
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.distributedLockTable=distributed_lock
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000

#These configurations are required if the `store mode` is `redis`. If `store.mode,store.lock.mode,store.session.mode` are not equal to `redis`, you can remove the configuration block.
store.redis.mode=single
store.redis.single.host=127.0.0.1
store.redis.single.port=6379
store.redis.sentinel.masterName=
store.redis.sentinel.sentinelHosts=
store.redis.sentinel.sentinelPassword=
store.redis.maxConn=10
store.redis.minConn=1
store.redis.maxTotal=100
store.redis.database=0
store.redis.password=
store.redis.queryLimit=100

#Transaction rule configuration, only for the server
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000
server.maxCommitRetryTimeout=-1
server.maxRollbackRetryTimeout=-1
server.rollbackFailedUnlockEnable=false
server.distributedLockExpireTime=10000
server.xaerNotaRetryTimeout=60000
server.session.branchAsyncQueueSize=5000
server.session.enableBranchAsyncRemove=false
server.enableParallelRequestHandle=false

#Metrics configuration, only for the server
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898

導入Seata-server所需SQL

  前面我們已經配置了Seata使用mysql作為db高可用數據庫(store.mode=db),故需要在mysql創建一個seata庫(store.db.url中配置的庫名),並導入數據庫腳本。可以從GitHub倉庫https://github.com/apache/incubator-seata/blob/develop/script/server/db下載不同數據庫的SQL腳本(直達鏈接:mysql數據庫腳本),下載後將SQL導入數據庫中。

-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(
    `xid`                       VARCHAR(128) NOT NULL,
    `transaction_id`            BIGINT,
    `status`                    TINYINT      NOT NULL,
    `application_id`            VARCHAR(32),
    `transaction_service_group` VARCHAR(32),
    `transaction_name`          VARCHAR(128),
    `timeout`                   INT,
    `begin_time`                BIGINT,
    `application_data`          VARCHAR(2000),
    `gmt_create`                DATETIME,
    `gmt_modified`              DATETIME,
    PRIMARY KEY (`xid`),
    KEY `idx_status_gmt_modified` (`status` , `gmt_modified`),
    KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(
    `branch_id`         BIGINT       NOT NULL,
    `xid`               VARCHAR(128) NOT NULL,
    `transaction_id`    BIGINT,
    `resource_group_id` VARCHAR(32),
    `resource_id`       VARCHAR(256),
    `branch_type`       VARCHAR(8),
    `status`            TINYINT,
    `client_id`         VARCHAR(64),
    `application_data`  VARCHAR(2000),
    `gmt_create`        DATETIME(6),
    `gmt_modified`      DATETIME(6),
    PRIMARY KEY (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
    `row_key`        VARCHAR(128) NOT NULL,
    `xid`            VARCHAR(128),
    `transaction_id` BIGINT,
    `branch_id`      BIGINT       NOT NULL,
    `resource_id`    VARCHAR(256),
    `table_name`     VARCHAR(32),
    `pk`             VARCHAR(36),
    `status`         TINYINT      NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
    `gmt_create`     DATETIME,
    `gmt_modified`   DATETIME,
    PRIMARY KEY (`row_key`),
    KEY `idx_status` (`status`),
    KEY `idx_branch_id` (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

CREATE TABLE IF NOT EXISTS `distributed_lock`
(
    `lock_key`       CHAR(20) NOT NULL,
    `lock_value`     VARCHAR(20) NOT NULL,
    `expire`         BIGINT,
    primary key (`lock_key`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);

  至此,Seata-server已經配置完成並且集成了Nacos,事務數據也存儲在DB中。可以點擊Seata目錄下bin/seata-server.bat腳本啓動Seata-server。
image

搭建測試微服務

創建測試庫及表

# 訂單數據庫信息 seata_order
DROP DATABASE IF EXISTS seata_order;
CREATE DATABASE seata_order;

DROP TABLE IF EXISTS seata_order.p_order;
CREATE TABLE seata_order.p_order
(
    id               INT(11) NOT NULL AUTO_INCREMENT,
    user_id          INT(11) DEFAULT NULL,
    product_id       INT(11) DEFAULT NULL,
    amount           INT(11) DEFAULT NULL,
    total_price      DOUBLE       DEFAULT NULL,
    status           VARCHAR(100) DEFAULT NULL,
    add_time         DATETIME     DEFAULT CURRENT_TIMESTAMP,
    last_update_time DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8mb4;

DROP TABLE IF EXISTS seata_order.undo_log;
CREATE TABLE seata_order.undo_log
(
    id            BIGINT(20) NOT NULL AUTO_INCREMENT,
    branch_id     BIGINT(20) NOT NULL,
    xid           VARCHAR(100) NOT NULL,
    context       VARCHAR(128) NOT NULL,
    rollback_info LONGBLOB     NOT NULL,
    log_status    INT(11) NOT NULL,
    log_created   DATETIME     NOT NULL,
    log_modified  DATETIME     NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8mb4;
  
# 產品數據庫信息 seata_product
DROP DATABASE IF EXISTS seata_product;
CREATE DATABASE seata_product;

DROP TABLE IF EXISTS seata_product.product;
CREATE TABLE seata_product.product
(
    id               INT(11) NOT NULL AUTO_INCREMENT,
    price            DOUBLE   DEFAULT NULL,
    stock            INT(11) DEFAULT NULL,
    last_update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8mb4;

DROP TABLE IF EXISTS seata_product.undo_log;
CREATE TABLE seata_product.undo_log
(
    id            BIGINT(20) NOT NULL AUTO_INCREMENT,
    branch_id     BIGINT(20) NOT NULL,
    xid           VARCHAR(100) NOT NULL,
    context       VARCHAR(128) NOT NULL,
    rollback_info LONGBLOB     NOT NULL,
    log_status    INT(11) NOT NULL,
    log_created   DATETIME     NOT NULL,
    log_modified  DATETIME     NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8mb4;

INSERT INTO seata_product.product (id, price, stock)
VALUES (1, 10, 20);


# 賬户數據庫信息 seata_account
DROP DATABASE IF EXISTS seata_account;
CREATE DATABASE seata_account;

DROP TABLE IF EXISTS seata_account.account;
CREATE TABLE seata_account.account
(
    id               INT(11) NOT NULL AUTO_INCREMENT,
    balance          DOUBLE   DEFAULT NULL,
    last_update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8mb4;

DROP TABLE IF EXISTS seata_account.undo_log;
CREATE TABLE seata_account.undo_log
(
    id            BIGINT(20) NOT NULL AUTO_INCREMENT,
    branch_id     BIGINT(20) NOT NULL,
    xid           VARCHAR(100) NOT NULL,
    context       VARCHAR(128) NOT NULL,
    rollback_info LONGBLOB     NOT NULL,
    log_status    INT(11) NOT NULL,
    log_created   DATETIME     NOT NULL,
    log_modified  DATETIME     NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8mb4;
INSERT INTO seata_account.account (id, balance)
VALUES (1, 50);

  其中,每個庫中的undo_log表,是Seata AT模式必須創建的表,主要用於分支事務的回滾,undo_log表可以從GitHub倉庫https://github.com/apache/incubator-seata/tree/develop/script/client/at/db下載不同數據庫的SQL腳本(直達鏈接:mysql數據庫腳本)。另外,考慮到測試方便,我們插入了一條id = 1的account記錄,和一條id = 1的product記錄。

搭建測試服務

搭建賬户服務

  在ruoyi-modules新建一個Module:ruoyi-account,為了搭建方便,以及減少Maven依賴,pom依賴直接拷貝ruoyi-system中的依賴,然後新增Seata的依賴就可以了。
  ruoyi-account的pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.ruoyi</groupId>
        <artifactId>ruoyi-modules</artifactId>
        <version>3.6.6</version>
    </parent>

    <artifactId>ruoyi-account</artifactId>
    <packaging>jar</packaging>

    <name>ruoyi-account</name>
    <url>http://maven.apache.org</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- SpringCloud Alibaba Nacos -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <!-- SpringCloud Alibaba Nacos Config -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

        <!-- SpringCloud Alibaba Sentinel -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        </dependency>

        <!-- SpringBoot Actuator -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- Mysql Connector -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

        <!-- RuoYi Common DataSource -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-datasource</artifactId>
        </dependency>

        <!-- RuoYi Common DataScope -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-datascope</artifactId>
        </dependency>

        <!-- RuoYi Common Log -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-log</artifactId>
        </dependency>

        <!-- RuoYi Common Swagger -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-swagger</artifactId>
        </dependency>

        <!-- SpringBoot Seata -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>


    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

  ruoyi-account的bootstrap.yml:

# Tomcat
server:
  port: 10300

# Spring
spring: 
  application:
    # 應用名稱
    name: ruoyi-account
  profiles:
    # 環境配置
    active: dev
  cloud:
    nacos:
      discovery:
        # 服務註冊地址
        server-addr: 127.0.0.1:8848
      config:
        # 配置中心地址
        server-addr: 127.0.0.1:8848
        # 配置文件格式
        file-extension: yml
        # 共享配置
        shared-configs:
          - application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
seata:
  enabled: true
  # Seata 應用編號,默認為 ${spring.application.name}
  application-id: ${spring.application.name}
  # Seata 事務組編號,用於 TC 集羣名
  tx-service-group: ${spring.application.name}-group
  # 關閉自動代理
  enable-auto-data-source-proxy: false
  # 服務配置項
  service:
    # 虛擬組和分組的映射
    vgroup-mapping:
      ruoyi-account-group: default
  config:
    type: nacos
    nacos:
      serverAddr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace:
      dataId: seataServer.properties
  registry:
    type: nacos
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      namespace:

  ruoyi-account的bootstrap.yml也是直接拷貝ruoyi-system中的bootstrap.yml,然後修改服務端口,服務名;為了演示方便,關於Seata的配置也直接寫在了bootstrap.yml。
  ruoyi-account的ruoyi-account-dev.yml:

# spring配置
spring:
  redis:
    host: localhost
    port: 6379
    password: 
  datasource:
    druid:
      stat-view-servlet:
        enabled: true
        loginUsername: ruoyi
        loginPassword: 123456
    dynamic:
      druid:
        initial-size: 5
        min-idle: 5
        maxActive: 20
        maxWait: 60000
        connectTimeout: 30000
        socketTimeout: 60000
        timeBetweenEvictionRunsMillis: 60000
        minEvictableIdleTimeMillis: 300000
        validationQuery: SELECT 1 FROM DUAL
        testWhileIdle: true
        testOnBorrow: false
        testOnReturn: false
        poolPreparedStatements: true
        maxPoolPreparedStatementPerConnectionSize: 20
        filters: stat,slf4j
        connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
      datasource:
          # 主庫數據源
          master:
            driver-class-name: com.mysql.cj.jdbc.Driver
            url: jdbc:mysql://localhost:3306/seata_account?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
            username: root
            password: root123
          # 從庫數據源
          # slave:
            # username: 
            # password: 
            # url: 
            # driver-class-name: 
      seata: true #開啓seata代理,開啓後默認每個數據源都代理,如果某個不需要代理可單獨關閉

# mybatis配置
mybatis:
    # 搜索指定包別名
    typeAliasesPackage: com.ruoyi.account
    # 配置mapper的掃描,找到所有的mapper.xml映射文件
    mapperLocations: classpath:mapper/**/*.xml

# springdoc配置
springdoc:
  gatewayUrl: http://localhost:8080/${spring.application.name}
  api-docs:
    # 是否開啓接口文檔
    enabled: false

  ruoyi-account的ruoyi-account-dev.yml也是直接拷貝ruoyi-system中的ruoyi-system-dev.yml,僅修改數據庫連接信息,MyBatis掃描的包等,唯一比較重要的一點是動態數據源這裏配置了seata:true,開啓Seata代理。
  ruoyi-account示例代碼:
  Account.java


package com.ruoyi.account.domain;

import lombok.Getter;
import lombok.Setter;

import java.util.Date;

@Getter
@Setter
public class Account {

    private Long id;

    /**
     * 餘額
     */
    private Double balance;

    private Date lastUpdateTime;

}

  AccountMapper.java

package com.ruoyi.account.mapper;

import com.ruoyi.account.domain.Account;

public interface AccountMapper {
    public Account selectById(Long userId);

    public void updateById(Account account);
}

  AccountMapper.xml

<?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.ruoyi.account.mapper.AccountMapper">

    <resultMap type="com.ruoyi.account.domain.Account" id="AccountResult">
        <id     property="id"              column="id"                />
        <result property="balance"         column="balance"           />
        <result property="lastUpdateTime"  column="last_update_time"  />
    </resultMap>

    <select id="selectById" parameterType="com.ruoyi.account.domain.Account" resultMap="AccountResult">
        select id, balance, last_update_time
        from account where id = #{userId}
    </select>

    <update id="updateById" parameterType="com.ruoyi.account.domain.Account">
        update account set balance = #{balance}, last_update_time = sysdate() where id = #{id}
    </update>

</mapper>

  AccountService.java

package com.ruoyi.account.service;

public interface AccountService {
    /**
     * 賬户扣減
     * @param userId 用户 ID
     * @param price 扣減金額
     */
    void reduceBalance(Long userId, Double price);
}

  AccountServiceImpl.java

package com.ruoyi.account.service.impl;

import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.dynamic.datasource.annotation.DS;
import com.ruoyi.account.domain.Account;
import com.ruoyi.account.mapper.AccountMapper;
import com.ruoyi.account.service.AccountService;
import io.seata.core.context.RootContext;

@Service
public class AccountServiceImpl implements AccountService
{
    private static final Logger log = LoggerFactory.getLogger(AccountServiceImpl.class);
    
    @Resource
    private AccountMapper accountMapper;

    /**
     * 事務傳播特性設置為 REQUIRES_NEW 開啓新的事務 重要!!!!一定要使用REQUIRES_NEW
     * 在若依的示例中,事務的傳播特性必須設置REQUIRES_NEW,但經本人測試,多服務調用事務
     * 設置成默認的REQUIRED也能回滾成功
     */
    @Override
    @Transactional
    public void reduceBalance(Long userId, Double price)
    {
        log.info("=============ACCOUNT START=================");
        log.info("當前 XID: {}", RootContext.getXID());

        Account account = accountMapper.selectById(userId);
        Double balance = account.getBalance();
        log.info("下單用户{}餘額為 {},商品總價為{}", userId, balance, price);

        if (balance < price)
        {
            log.warn("用户 {} 餘額不足,當前餘額:{}", userId, balance);
            throw new RuntimeException("餘額不足");
        }
        log.info("開始扣減用户 {} 餘額", userId);
        double currentBalance = account.getBalance() - price;
        account.setBalance(currentBalance);
        accountMapper.updateById(account);
        log.info("扣減用户 {} 餘額成功,扣減後用户賬户餘額為{}", userId, currentBalance);
        log.info("=============ACCOUNT END=================");
    }

}

  AccountController.java

package com.ruoyi.account.controller;

import com.ruoyi.account.dto.ReduceBalanceRequest;
import com.ruoyi.account.service.AccountService;
import com.ruoyi.common.core.domain.R;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/account")
public class AccountController {

    @Autowired
    private AccountService accountService;

    @PostMapping("/reduceBalance")
    public R reduceBalance(@Validated @RequestBody ReduceBalanceRequest request) {
        accountService.reduceBalance(request.getUserId(), request.getPrice());
        return R.ok("下單成功");
    }
}

  ReduceBalanceRequest.java

package com.ruoyi.account.dto;


import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReduceBalanceRequest {
    private Long userId;

    private Double price;
}

  至此,賬户服務就搭建好了,這裏主要提供一個扣減賬户餘額的接口。

搭建商品服務

  同樣,在ruoyi-modules新建一個Module:ruoyi-product。
  ruoyi-product的pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.ruoyi</groupId>
        <artifactId>ruoyi-modules</artifactId>
        <version>3.6.6</version>
    </parent>

    <artifactId>ruoyi-product</artifactId>
    <packaging>jar</packaging>

    <name>ruoyi-product</name>
    <url>http://maven.apache.org</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- SpringCloud Alibaba Nacos -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <!-- SpringCloud Alibaba Nacos Config -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

        <!-- SpringCloud Alibaba Sentinel -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        </dependency>

        <!-- SpringBoot Actuator -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- Mysql Connector -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

        <!-- RuoYi Common DataSource -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-datasource</artifactId>
        </dependency>

        <!-- RuoYi Common DataScope -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-datascope</artifactId>
        </dependency>

        <!-- RuoYi Common Log -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-log</artifactId>
        </dependency>

        <!-- RuoYi Common Swagger -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-swagger</artifactId>
        </dependency>

        <!-- SpringBoot Seata -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>


  ruoyi-product的bootstrap.yml:

# Tomcat
server:
  port: 10302

# Spring
spring: 
  application:
    # 應用名稱
    name: ruoyi-product
  profiles:
    # 環境配置
    active: dev
  cloud:
    nacos:
      discovery:
        # 服務註冊地址
        server-addr: 127.0.0.1:8848
      config:
        # 配置中心地址
        server-addr: 127.0.0.1:8848
        # 配置文件格式
        file-extension: yml
        # 共享配置
        shared-configs:
          - application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
seata:
  enabled: true
  # Seata 應用編號,默認為 ${spring.application.name}
  application-id: ${spring.application.name}
  # Seata 事務組編號,用於 TC 集羣名
  tx-service-group: ${spring.application.name}-group
  # 關閉自動代理
  enable-auto-data-source-proxy: false
  # 服務配置項
  service:
    # 虛擬組和分組的映射
    vgroup-mapping:
      ruoyi-product-group: default
  config:
    type: nacos
    nacos:
      serverAddr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace:
      dataId: seataServer.properties
  registry:
    type: nacos
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      namespace:

  ruoyi-product的ruoyi-product-dev.yml:

# spring配置
spring:
  redis:
    host: localhost
    port: 6379
    password: 
  datasource:
    druid:
      stat-view-servlet:
        enabled: true
        loginUsername: ruoyi
        loginPassword: 123456
    dynamic:
      druid:
        initial-size: 5
        min-idle: 5
        maxActive: 20
        maxWait: 60000
        connectTimeout: 30000
        socketTimeout: 60000
        timeBetweenEvictionRunsMillis: 60000
        minEvictableIdleTimeMillis: 300000
        validationQuery: SELECT 1 FROM DUAL
        testWhileIdle: true
        testOnBorrow: false
        testOnReturn: false
        poolPreparedStatements: true
        maxPoolPreparedStatementPerConnectionSize: 20
        filters: stat,slf4j
        connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
      datasource:
          # 主庫數據源
          master:
            driver-class-name: com.mysql.cj.jdbc.Driver
            url: jdbc:mysql://localhost:3306/seata_product?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
            username: root
            password: root123
          # 從庫數據源
          # slave:
            # username: 
            # password: 
            # url: 
            # driver-class-name: 
      seata: true

# mybatis配置
mybatis:
    # 搜索指定包別名
    typeAliasesPackage: com.ruoyi.product
    # 配置mapper的掃描,找到所有的mapper.xml映射文件
    mapperLocations: classpath:mapper/**/*.xml

# springdoc配置
springdoc:
  gatewayUrl: http://localhost:8080/${spring.application.name}
  api-docs:
    # 是否開啓接口文檔
    enabled: false

  ruoyi-product示例代碼:
  Product.java


package com.ruoyi.product.domain;

import lombok.Getter;
import lombok.Setter;

import java.util.Date;

@Getter
@Setter
public class Product {

    private Integer id;
    /**
     * 價格
     */
    private Double price;
    /**
     * 庫存
     */
    private Integer stock;

    private Date lastUpdateTime;

}

  ProductMapper.java

package com.ruoyi.product.mapper;

import com.ruoyi.product.domain.Product;

public interface ProductMapper {
    public Product selectById(Long productId);

    public void updateById(Product product);
}

  ProductMapper.xml

<?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.ruoyi.product.mapper.ProductMapper">

    <resultMap type="com.ruoyi.product.domain.Product" id="ProductResult">
        <id     property="id"              column="id"                />
        <result property="price"           column="price"             />
        <result property="stock"           column="stock"             />
        <result property="lastUpdateTime"  column="last_update_time"  />
    </resultMap>

    <select id="selectById" parameterType="com.ruoyi.product.domain.Product" resultMap="ProductResult">
        select id, price, stock, last_update_time
        from product where id = #{productId}
    </select>

    <update id="updateById" parameterType="com.ruoyi.product.domain.Product">
        update product set price = #{price}, stock = #{stock}, last_update_time = sysdate() where id = #{id}
    </update>

</mapper>

  ProductService.java

package com.ruoyi.product.service;

public interface ProductService {
    /**
     * 扣減庫存
     *
     * @param productId 商品 ID
     * @param amount 扣減數量
     * @return 商品總價
     */
    Double reduceStock(Long productId, Integer amount);
}

  ProductServiceImpl.java

package com.ruoyi.product.service.impl;

import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.dynamic.datasource.annotation.DS;
import com.ruoyi.product.domain.Product;
import com.ruoyi.product.mapper.ProductMapper;
import com.ruoyi.product.service.ProductService;
import io.seata.core.context.RootContext;

@Service
public class ProductServiceImpl implements ProductService {
    private static final Logger log = LoggerFactory.getLogger(ProductServiceImpl.class);

    @Resource
    private ProductMapper productMapper;

    /**
     * 事務傳播特性設置為 REQUIRES_NEW 開啓新的事務 重要!!!!一定要使用REQUIRES_NEW
     * 在若依的示例中,事務的傳播特性必須設置REQUIRES_NEW,但經本人測試,多服務調用事務
     * 設置成默認的REQUIRED也能回滾成功
     */
    @Transactional
    @Override
    public Double reduceStock(Long productId, Integer amount)
    {
        log.info("=============PRODUCT START=================");
        log.info("當前 XID: {}", RootContext.getXID());

        // 檢查庫存
        Product product = productMapper.selectById(productId);
        Integer stock = product.getStock();
        log.info("商品編號為 {} 的庫存為{},訂單商品數量為{}", productId, stock, amount);

        if (stock < amount)
        {
            log.warn("商品編號為{} 庫存不足,當前庫存:{}", productId, stock);
            throw new RuntimeException("庫存不足");
        }
        log.info("開始扣減商品編號為 {} 庫存,單價商品價格為{}", productId, product.getPrice());
        // 扣減庫存
        int currentStock = stock - amount;
        product.setStock(currentStock);
        productMapper.updateById(product);
        double totalPrice = product.getPrice() * amount;
        log.info("扣減商品編號為 {} 庫存成功,扣減後庫存為{}, {} 件商品總價為 {} ", productId, currentStock, amount, totalPrice);
        log.info("=============PRODUCT END=================");
        return totalPrice;
    }

}

  ProductController.java

package com.ruoyi.product.controller;

import com.ruoyi.common.core.domain.R;
import com.ruoyi.product.dto.ReduceStockRequest;
import com.ruoyi.product.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/product")
public class ProductController {

    @Autowired
    private ProductService productService;

    @PostMapping("/reduceStock")
    public R<Double> reduceStock(@Validated @RequestBody ReduceStockRequest request) {
        Double d = productService.reduceStock(request.getProductId(), request.getAmount());
        return R.ok(d);
    }
}

  ReduceStockRequest.java

package com.ruoyi.product.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReduceStockRequest {
    private Long productId;

    private Integer amount;

}

  至此,商品服務就搭建好了,這裏主要提供一個扣減商品庫存的接口。

創建服務調用模塊

  在ruoyi-modules新建一個Module:ruoyi-call(當你也可以不新建Module,直接把Feign接口寫在具調用的服務中,這裏新建Module主是為了更好的體現模塊之間的低耦合),該模塊主要的作用是提供Feign接口,用於訂單服務調用商品及賬户服務,為了搭建方便,以及減少Maven依賴,pom依賴直接拷貝ruoyi-api-system中的依賴。
  ruoyi-call的pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.ruoyi</groupId>
        <artifactId>ruoyi-modules</artifactId>
        <version>3.6.6</version>
    </parent>

    <artifactId>ruoyi-call</artifactId>
    <packaging>jar</packaging>

    <name>ruoyi-call</name>
    <url>http://maven.apache.org</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-core</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
</project>

  ruoyi-call示例代碼:
  AccountFeignService.java


package com.ruoyi.call.feign;

import com.ruoyi.call.dto.ReduceBalanceRequest;
import com.ruoyi.common.core.domain.R;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@FeignClient(name = "ruoyi-account")
public interface AccountFeignService {

    @PostMapping("/account/reduceBalance")
    R reduceBalance(@RequestBody ReduceBalanceRequest request);
}

  ProductFeignService.java

package com.ruoyi.call.feign;

import com.ruoyi.call.dto.ReduceStockRequest;
import com.ruoyi.common.core.domain.R;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@FeignClient(name = "ruoyi-product")
public interface ProductFeignService {

    @PostMapping("/product/reduceStock")
    R<Double> reduceStock(@RequestBody ReduceStockRequest request);
}

  ReduceBalanceRequest.java

package com.ruoyi.call.dto;


import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReduceBalanceRequest {
    private Long userId;

    private Double price;
}

  ReduceStockRequest.java

package com.ruoyi.call.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReduceStockRequest {
    private Long productId;

    private Integer amount;

}

  至此,服務調用模塊就創建好了,這裏主要提供Feign接口,供訂單服務調用。

搭建訂單服務

  在ruoyi-modules新建一個Module:ruoyi-order,為了搭建方便,以及減少Maven依賴,pom依賴直接拷貝ruoyi-system中的依賴,然後新增Seata的依賴,再就是引入創剛建的服務調用模塊。
  ruoyi-order的pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.ruoyi</groupId>
        <artifactId>ruoyi-modules</artifactId>
        <version>3.6.6</version>
    </parent>

    <artifactId>ruoyi-order</artifactId>
    <packaging>jar</packaging>

    <name>ruoyi-order</name>
    <url>http://maven.apache.org</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- SpringCloud Alibaba Nacos -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <!-- SpringCloud Alibaba Nacos Config -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

        <!-- SpringCloud Alibaba Sentinel -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        </dependency>

        <!-- SpringBoot Actuator -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- Mysql Connector -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

        <!-- RuoYi Common DataSource -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-datasource</artifactId>
        </dependency>

        <!-- RuoYi Common DataScope -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-datascope</artifactId>
        </dependency>

        <!-- RuoYi Common Log -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-log</artifactId>
        </dependency>

        <!-- RuoYi Common Swagger -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-swagger</artifactId>
        </dependency>

        <!-- SpringBoot Seata -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
        </dependency>

        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-call</artifactId>
            <version>3.6.6</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

  ruoyi-order的bootstrap.yml:

# Tomcat
server:
  port: 10301

# Spring
spring: 
  application:
    # 應用名稱
    name: ruoyi-order
  profiles:
    # 環境配置
    active: dev
  cloud:
    nacos:
      discovery:
        # 服務註冊地址
        server-addr: 127.0.0.1:8848
      config:
        # 配置中心地址
        server-addr: 127.0.0.1:8848
        # 配置文件格式
        file-extension: yml
        # 共享配置
        shared-configs:
          - application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
seata:
  enabled: true
  # Seata 應用編號,默認為 ${spring.application.name}
  application-id: ${spring.application.name}
  # Seata 事務組編號,用於 TC 集羣名
  tx-service-group: ${spring.application.name}-group
  # 關閉自動代理
  enable-auto-data-source-proxy: false
  # 服務配置項
  service:
    # 虛擬組和分組的映射
    vgroup-mapping:
      ruoyi-order-group: default
  config:
    type: nacos
    nacos:
      serverAddr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace:
      dataId: seataServer.properties
  registry:
    type: nacos
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      namespace:

  ruoyi-order的ruoyi-order-dev.yml:

# spring配置
spring:
  redis:
    host: localhost
    port: 6379
    password: 
  datasource:
    druid:
      stat-view-servlet:
        enabled: true
        loginUsername: ruoyi
        loginPassword: 123456
    dynamic:
      druid:
        initial-size: 5
        min-idle: 5
        maxActive: 20
        maxWait: 60000
        connectTimeout: 30000
        socketTimeout: 60000
        timeBetweenEvictionRunsMillis: 60000
        minEvictableIdleTimeMillis: 300000
        validationQuery: SELECT 1 FROM DUAL
        testWhileIdle: true
        testOnBorrow: false
        testOnReturn: false
        poolPreparedStatements: true
        maxPoolPreparedStatementPerConnectionSize: 20
        filters: stat,slf4j
        connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
      datasource:
          # 主庫數據源
          master:
            driver-class-name: com.mysql.cj.jdbc.Driver
            url: jdbc:mysql://localhost:3306/seata_order?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
            username: root
            password: root123
          # 從庫數據源
          # slave:
            # username: 
            # password: 
            # url: 
            # driver-class-name: 
      seata: true

# mybatis配置
mybatis:
    # 搜索指定包別名
    typeAliasesPackage: com.ruoyi.order
    # 配置mapper的掃描,找到所有的mapper.xml映射文件
    mapperLocations: classpath:mapper/**/*.xml

# springdoc配置
springdoc:
  gatewayUrl: http://localhost:8080/${spring.application.name}
  api-docs:
    # 是否開啓接口文檔
    enabled: false

  ruoyi-order示例代碼:
  Order.java

package com.ruoyi.order.domain;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class Order {

    private Integer id;

    /**
     * 用户ID
     */
    private Long userId;

    /**
     * 商品ID
     */
    private Long productId;

    /**
     * 訂單狀態
     */
    private int status;

    /**
     * 數量
     */
    private Integer amount;

    /**
     * 總金額
     */
    private Double totalPrice;

    public Order()
    {
    }

    public Order(Long userId, Long productId, int status, Integer amount)
    {
        this.userId = userId;
        this.productId = productId;
        this.status = status;
        this.amount = amount;
    }
}

  OrderMapper.java

package com.ruoyi.order.mapper;

import com.ruoyi.order.domain.Order;

public interface OrderMapper {
    public void insert(Order order);

    public void updateById(Order order);
}

  OrderMapper.xml

<?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.ruoyi.order.mapper.OrderMapper">

    <resultMap type="com.ruoyi.order.domain.Order" id="OrderResult">
        <id     property="id"              column="id"                />
        <result property="userId"          column="user_id"           />
        <result property="productId"       column="product_id"        />
        <result property="amount"          column="amount"            />
        <result property="totalPrice"      column="total_price"       />
        <result property="status"          column="status"            />
    </resultMap>

    <insert id="insert" parameterType="com.ruoyi.order.domain.Order" useGeneratedKeys="true" keyProperty="id">
        insert into p_order (
        <if test="userId != null and userId != '' ">user_id,</if>
        <if test="productId != null and productId != '' ">product_id,</if>
        <if test="amount != null and amount != '' ">amount,</if>
        <if test="totalPrice != null and totalPrice != '' ">total_price,</if>
        <if test="status != null and status != ''">status,</if>
        add_time
        )values(
        <if test="userId != null and userId != ''">#{userId},</if>
        <if test="productId != null and productId != ''">#{productId},</if>
        <if test="amount != null and amount != ''">#{amount},</if>
        <if test="totalPrice != null and totalPrice != ''">#{totalPrice},</if>
        <if test="status != null and status != ''">#{status},</if>
        sysdate()
        )
    </insert>

    <update id="updateById" parameterType="com.ruoyi.order.domain.Order">
        update p_order
        <set>
            <if test="userId != null and userId != ''">user_id = #{userId},</if>
            <if test="productId != null and productId != ''">product_id = #{productId},</if>
            <if test="amount != null and amount != ''">amount = #{amount},</if>
            <if test="totalPrice != null and totalPrice != ''">total_price = #{totalPrice},</if>
            <if test="status != null and status != ''">status = #{status},</if>
            last_update_time = sysdate()
        </set>
        where id = #{id}
    </update>

</mapper>

  OrderService.java

package com.ruoyi.order.service;

import com.ruoyi.order.dto.PlaceOrderRequest;

public interface OrderService {
    /**
     * 下單
     *
     * @param placeOrderRequest 訂單請求參數
     */
    void placeOrder(PlaceOrderRequest placeOrderRequest);
}

  OrderServiceImpl.java

package com.ruoyi.order.service.impl;

import javax.annotation.Resource;

import com.ruoyi.call.dto.ReduceBalanceRequest;
import com.ruoyi.call.dto.ReduceStockRequest;
import com.ruoyi.call.feign.AccountFeignService;
import com.ruoyi.call.feign.ProductFeignService;
import com.ruoyi.common.core.domain.R;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.dynamic.datasource.annotation.DS;
import com.ruoyi.order.domain.Order;
import com.ruoyi.order.dto.PlaceOrderRequest;
import com.ruoyi.order.mapper.OrderMapper;
import com.ruoyi.order.service.OrderService;
import io.seata.core.context.RootContext;
import io.seata.spring.annotation.GlobalTransactional;

@Service
public class OrderServiceImpl implements OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderServiceImpl.class);

    @Resource
    private OrderMapper orderMapper;

    @Autowired
    private ProductFeignService productFeignService;

    @Autowired
    private AccountFeignService accountFeignService;

    @Override
    @Transactional
    @GlobalTransactional // 重點 第一個開啓事務的需要添加seata全局事務註解
    public void placeOrder(PlaceOrderRequest request) {
        log.info("=============ORDER START=================");
        Long userId = request.getUserId();
        Long productId = request.getProductId();
        Integer amount = request.getAmount();
        log.info("收到下單請求,用户:{}, 商品:{},數量:{}", userId, productId, amount);

        log.info("當前 XID: {}", RootContext.getXID());

        Order order = new Order(userId, productId, 0, amount);

        orderMapper.insert(order);
        log.info("訂單一階段生成,等待扣庫存付款中");
        // 扣減庫存並計算總價
//        Double totalPrice = productService.reduceStock(productId, amount);
//        // 扣減餘額
//        accountService.reduceBalance(userId, totalPrice);

        // 扣減庫存並計算總價
        R<Double> r = productFeignService.reduceStock(new ReduceStockRequest(productId, amount));
        if (r.getCode() != 200) {
           throw new RuntimeException("扣減庫存失敗");
        }
        Double totalPrice = r.getData();
        // 扣減餘額
        R r1 = accountFeignService.reduceBalance(new ReduceBalanceRequest(userId, totalPrice));
        if (r1.getCode() != 200) {
            throw new RuntimeException("扣減餘額失敗");
        }
        order.setStatus(1);
        order.setTotalPrice(totalPrice);
        orderMapper.updateById(order);
        log.info("訂單已成功下單");
        log.info("=============ORDER END=================");
    }

}

  OrderController.java

package com.ruoyi.order.controller;

import com.ruoyi.common.core.domain.R;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.order.dto.PlaceOrderRequest;
import com.ruoyi.order.service.OrderService;

@RestController
@RequestMapping("/order")
public class OrderController {
    @Autowired
    private OrderService orderService;

    @PostMapping("/placeOrder")
    public R placeOrder(@Validated @RequestBody PlaceOrderRequest request) {
        orderService.placeOrder(request);
        return R.ok("下單成功");
    }

    @PostMapping("/test1")
    public R test1() {
        // 商品單價10元,庫存20個,用户餘額50元,模擬一次性購買22個。 期望異常回滾
        orderService.placeOrder(new PlaceOrderRequest(1L, 1L, 22));
        return R.ok("下單成功");
    }

    @PostMapping("/test2")
    public R test2() {
        // 商品單價10元,庫存20個,用户餘額50元,模擬一次性購買6個。 期望異常回滾
        orderService.placeOrder(new PlaceOrderRequest(1L, 1L, 6));
        return R.ok("下單成功");
    }
}

  PlaceOrderRequest.java

package com.ruoyi.order.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class PlaceOrderRequest {

    private Long userId;

    private Long productId;

    private Integer amount;

    public PlaceOrderRequest() {
    }

    public PlaceOrderRequest(Long userId, Long productId, Integer amount) {
        this.userId = userId;
        this.productId = productId;
        this.amount = amount;
    }
}

  至此,訂單服務就搭建好了。訂單服務提供了下單接口,可以模擬正常下單,同時提供了驗證庫存不足及餘額不足的接口,用於驗證事務回滾。

測試驗證

  使用接口測試工具Postman或Apifox測試接口,注意觀察運行日誌,以及數據庫數據變化,至此分佈式事務集成案例全流程完畢。

正常下單

  模擬正常下單,買一個商品 http://localhost:10301/order/placeOrder

{
    "userId": 1,
    "productId": 1,
    "amount": 1
}

庫存不足

  模擬庫存不足,事務回滾 http://localhost:9201/order/placeOrder

{
    "userId": 1,
    "productId": 1,
    "amount": 22
}

用户餘額不足

模擬用户餘額不足,事務回滾 http://localhost:9201/order/placeOrder

{
    "userId": 1,
    "productId": 1,
    "amount": 6
}

結語

  Seata AT模式是微服務場景下最常用的分佈式事務解決方案之一,核心優勢是對業務無侵入(僅需添加註解),底層基於「兩階段提交+自動補償」實現數據一致性。AT模式的設計目標是:讓開發者像使用本地事務一樣使用分佈式事務,無需手動編寫回滾邏輯。其核心依賴「事務協調器(TC)、資源管理器(RM)、事務管理器(TM)」三大組件,以及「undo_log日誌表、全局鎖、本地鎖」三大核心機制。在AT模式的兩階段提交中,第一階段執行本地事務時,Seata會自動攔截業務SQL,生成包含數據舊值的undo_log並與業務數據一同提交至數據庫;第二階段若需回滾,框架則通過undo_log反向執行更新操作,完成數據恢復。
  具體使用時只需將@GlobalTransactional註解添加在分佈式事務的發起方方法上,Seata便會自動完成全局事務ID的生成、分支事務的註冊與協調。使用時具體的注意事項包括:必須使用Seata數據源代理、必須創建undo_log表、所有微服務的tx-service-group、service.vgroupMapping需與Seata Server配置一致等。
  技術探索之路漫漫,由於作者水平有限,文中難免有疏漏或不妥之處,若有不同見解或優化建議,歡迎留言交流指正。

參考資料

https://seata.apache.org/zh-cn/docs/user/quickstart
https://doc.ruoyi.vip/ruoyi-cloud/cloud/seata.html#基本介紹
https://xie.infoq.cn/article/37af299e60562cf625029c29e

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.