分布式事务解决方案汇总(pom文件在最下面)

一、2阶段(2PC)提交方案:
 
实现原理:基于XA规范搞的一套分布式事务的理论,也可以叫做一套规范,或者是协议。
(1)准备阶段(Prepare phase):事务管理器给每个参与者发送prepare消息,每个数据库参与者在本地执行事务,并写本地的Undo/Redo,此时事务没有提交。
(2)提交阶段(Commit phase):如果事务管理器接收了参与者执行失败或者超时消息时,直接给每个参与者发送回滚消息,
否则发送提交消息;参与者根据事务管理器的指令执行提交或者回滚操作,并释放事务处理过程中使用的锁资源。
 
代码实现参考:
public static void main(String[] args) throws SQLException, XAException {

        // 获得资源管理器操作接口实例 RM1
        Connection conn1 = DriverManager.getConnection("jdbc:mysql://localhost:3306/dev", "root", "123456");
        XAConnection xaConn1 = new MysqlXAConnection((JdbcConnection) conn1, true);
        XAResource db1 = xaConn1.getXAResource();

        // 获得资源管理器操作接口实例 RM2
        Connection conn2 = DriverManager.getConnection("jdbc:mysql://localhost:3306/db2", "root", "123456");
        XAConnection xaConn2 = new MysqlXAConnection((JdbcConnection) conn2, true);
        XAResource db2 = xaConn2.getXAResource();
        // 应用程序 请求事务管理器 执行一个分布式事务,事务管理器 生成全局事务id
        byte[] gtrid = "g12345".getBytes();
        Xid id1 = new MysqlXid(gtrid, "b00001".getBytes(), 1); // 事务管理器生成 db1上的事务分支id
        Xid id2 = new MysqlXid(gtrid, "b00002".getBytes(), 1); // 事务管理器生成db2上的事务分支id

        try {
            // 执行db1上的事务分支
            db1.start(id1, XAResource.TMNOFLAGS);
            PreparedStatement ps1 = conn1.prepareStatement("INSERT into user_info(user_id, user_name) VALUES ('5', 'test')");
            ps1.execute();
            db1.end(id1, XAResource.TMSUCCESS);

            // 执行db2上的事务分支
            db2.start(id2, XAResource.TMNOFLAGS);
            PreparedStatement ps2 = conn2.prepareStatement("INSERT into accounts(userId, accountNumber) VALUES (2, 10000)");
            ps2.execute();
            db2.end(id2, XAResource.TMSUCCESS);

            // 准备两阶段提交  phase1:询问所有的RM 准备提交事务分支
            int rm1Prepare = db1.prepare(id1);
            int rm2Prepare = db2.prepare(id2);

            //提交所有事务分支, TM判断有2个事务分支,所以不能优化为一阶段提交
            if (rm1Prepare == XAResource.XA_OK && rm2Prepare == XAResource.XA_OK) {
                db1.commit(id1, false);
                db2.commit(id2, false);
                System.out.println("成功");
            } else {
                System.out.println("如果有事务分支没有成功,则回滚");
                db1.rollback(id1);
                db2.rollback(id2);
            }
            int a = 1/0;
        } catch (Exception e) {
            e.printStackTrace();
            db1.rollback(id1);
            db2.rollback(id2);
            System.out.println("异常error");
        }
    }
1.最好pom引入开源的分布式事务管理器,如Atomikos作为本地事务管理器。如:Spring Boot集成atomikos快速入门Demo
2.在分布式环境中,每个服务配置 Atomikos 作为本地事务管理器,但是全局事务的管理和协调是由一个独立的分布式事务协调器(DTC)来完成。
3.在分布式环境中,独立的分布式事务协调器(DTC)通常是一个单独的服务或组件。通常情况下,项目除了引入 Atomikos 作为本地事务管理器之外,还需要考虑如何部署和配置这个分布式事务协调器。
4.在分布式环境中,确保分布式事务的一致性和可靠性需要配合使用本地事务管理器(如 Atomikos)和一个独立的分布式事务协调器(DTC)。
 
二、3阶段提交 3pc
package org.example.fenbushi.threejieduan;

/**
 * 三阶段提交 3PC
 * 三阶段提交(3PC),是二阶段提交(2PC)的改进版本。
 * 与两阶段提交不同的是,三阶段提交有两个改动点:
 *     1、引入超时机制。同时在协调者和参与者中都引入超时机制。
 *     2、在第一阶段和第二阶段中插入一个预提交阶段。
 *     3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit(准备阶段)、PreCommit(预提交阶段)、DoCommit(提交阶段)三个阶段。
 */
public class threeJieDuan {

    /**
     * 1. 准备阶段(CanCommit Phase)
     * 协调者向所有参与者发送“准备提交”请求。
     * 参与者响应是否可以准备提交(“CanCommit”)。
     *
     * 2. 预提交阶段(PreCommit Phase)
     * 如果所有参与者都返回可以准备提交,协调者向所有参与者发送“预提交”请求。
     * 参与者执行事务操作并记录预提交状态,但不真正提交事务,返回“预提交成功”。
     * 如果有任何参与者无法预提交,则协调者发送“中止”请求。
     *
     * 3. 提交阶段(Commit Phase)
     * 如果所有参与者都返回“预提交成功”,协调者向所有参与者发送“提交”请求,所有参与者提交事务。
     * 如果有任何参与者未能返回“预提交成功”,协调者发送“中止”请求。
     *
     * 优点:
     * 减少阻塞:通过预提交阶段,参与者可以确保在协调者失联时能够在一定时间之后自行决定提交或回滚。
     * 提高可用性:在协调者崩溃的情况下,通过预提交阶段的信息,参与者可以更智能地选择继续提交或回滚。
     *
     * 缺点:
     * 复杂性增加:三阶段提交协议比两阶段提交协议复杂,涉及更多的通信和协调。
     * 性能开销:由于多了一个阶段,协议执行效率较低。
     */


    /*
     * 相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。
     * 而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,
     * 因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。
     * 这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
     *
     *
     * 2PC,3PC总结
     * 2pc和3pc都是属于刚性事务,性能都会有影响,因为prapare阶段会锁资源,并且我们发现,这种模式大多数使用于单个项目多数据源,
     * 并不适合我们分布式的环境远程RPC调用。2PC只有事务管理的超时机制,3PC新增了参与者(资源管理器)的超时机制,3PC多了CanCommit阶段,这就是最大的区别。
     */

}
三、TCC事务

* 3.1.4 TCC事务会产生的问题一:幂等性
*     幂等性问题会发生在我们confirm和cancel阶段,try也会但是很少,当我们所有服务调用try接口成功的时候,我们会调用对应服务的confirm或者cancel,
*     这个时候由于网络原因超时了,会有一个retry的重试操作,网络超时代表我们并不知道confirm或者cancel的执行结果,假如你进行重试,
*     如果没保证幂等性就会产生数据的错误,所以我们必须要保证幂等性。
  
`解决方案:其实tcc事务执行,会有一个贯穿整个全局事务的全局事务id并且每一个分支事务会有一个分支事务id,我们每个微服务本地需要有一张分支本地事务日志表,
里面有的字段,全局事务id,分支事务id,分支事务执行状态status(1.try执行成功,2.confirm执行成功,3cancel执行成功),这样我们在重试的时候,
首先用分支事务id来作为锁的key,然后去查询本地事务表,我是否执行过这一步操作,如果执行过则不执行,这样就可以保证幂等性。
所以我们业务操作每一步操作的时候都需要在本地事务表记录当前分支事务的状态,和业务代码一起提交事务,这样可以回溯分支事务是否完成。`


* 3.1.5 TCC事务会产生的问题二:事务悬挂
*     还是网络原因产生的问题,假设我们设置了请求的超时时间为3秒,当我们对服务B执行try操作的时候,产生了超时,这个时候我们会调用B对应的cancel接口,
*     就是这么骚,我们的try还没执行,然后执行了cancel,这个时候就有可能产生脏数据。这个时候服务B执行try的线程有空回来了,然后执行了try操作,
*     那就完蛋了,我这个try操作永远都悬挂在这里了,芭比Q,因为我已经执行了cancel。

*     解决方案:全局事务id,分支事务id,分支本地事务日志表,在我们执行try操作之前,加锁,然后去本地事务表找一下当前有没有执行过cancel操作,有就不执行。

* 3.1.6 TCC事务会产生的问题三:空回滚
*     拿下面的下单流程为例,假设步骤1执行try成功了,然后步骤2失败了,这个时候框架会帮我们自动去调用步骤1,2,3,4的cancel方法,
*     然而2,3,4的try方法都还没执行,证明都还没开始预留资源,你他么就把我回滚了,我try还没扣余额,你cancel反倒还给我加余额,赢两次。这就是空回滚问题。
* 
*     解决方案:全局事务id,分支事务id,分支本地事务日志表,在我们执行cancel操作之前,加锁,然后去本地事务表找一下当前有没有执行过try操作,有就执行,没有就不执行,并且记录本地事务日志表状态。


* 3.1.7 TCC事务会产生的问题三:cancel或者confirm失败
*     有很多好奇的小朋友就要问,假设我所有try都成功了,或者有一些失败了,需要cancel和confirm的时候因为网络问题失败了,肿么办。不要慌,
*     本地会有个定时任务,定时去本地事务表日志扫描还未完成的事务,假设这个事务所有try都成功,有一部分confirm失败了,定时任务会不断去帮你执行confirm操作,
*     反之cancel。这个时候仍然需要依赖我们全局事务id,分支事务id,本地事务日志表。只要所有try成功,只能执行confirm操作,不能回滚因为所有资源都是正常锁定的,如果try有一个失败,才会执行回滚。
*     如果最后多次重试都失败必须人工补偿,要预警。

* 3.1.8 TCC事务会产生的问题四:宕机恢复
*     有很多好奇的小朋友就要问,假设我执行事务的中途宕机了怎么办,不要慌。项目启动的时候去去本地事务日志表扫描还没执行完的事务,然后去询问是不是所有分支事务
*     的try都执行完了啊,如果是,定时任务会不断去帮你执行confirm操作,反之cancel。这个时候仍然需要依赖我们全局事务id,分支事务id,本地事务日志表。
*     如果最后多次重试都失败必须人工补偿,要预警。

* 3.1.9 TCC等框架简单原理
*     本地事务日志表,全局事务id,分支事务id对于TCC分布式事务非常重要,但是一般框架都帮我们给实现了这个功能,包括幂等性,空回滚,失败重试,
*     宕机启动,包括整个try之后confirm或者cancel的自动调用,假如我们自己实现就需要解决这种问题。
*     一般这种框架原理是动态代理数据源,重写了commit等方法,在分布式事务开启的时候生成全局事务id,通过feign调用的时候,通过feign的拦截器,
*     把全局事务id在请求头带过去,假如调用的服务也是在分布式事务中的话,就生成分支事务id,然后在commit本地事务的时候把当前分支事务的状态也记录下来了,
*     因为在一个库所以能保证原子性。每个微服务本地事务commit之后try阶段结束,框架会自动帮我们调用和我们服务调用链相关联的confirm或者cancel。

* 3.2.0 TCC分布式事务总结
*     优点:并发高,不锁资源(2PC会锁数据库资源),本地事务提交即可,一般适用于资金等不能出错的场景。
*     缺点:复杂,需要一个业务接口需要拆成3个,业务侵入性非常之大,老系统改得你怀疑人生,如果自己实现还得记录本地事务日志,重试,
*     一般团队没实力不会使用这个,因为太骚了。
3.1 代码实现:
package org.example.fenbushi.tcc;

import lombok.extern.slf4j.Slf4j;
import org.mengyun.tcctransaction.api.Compensable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
public class SimplifiedController {

    @Autowired
    private TransferDao transferDao;

    //这是另一个服务Account服务
    @Autowired
    private IAccountService accountService;

    /*
     * 首先,服务A上需要加上注解@Compensable,成功的话就执行confirmMethod=方法,失败就执行cancelMethod=方法
     * 同理,服务B上被调用方法,也是需要加上这个注解,并且加上confirm 或者 cancel的方法.
     *
     * 采用TCC则认为Confirm阶段是不会出错的。即:只要Try成功,Confirm一定会成功。若Confirm阶段真的出错了,需要引入重试机制或人工处理。
     * Try操作可以为空,即什么也不做。例如A转20块给B(两个用户不同库),Try的时候A扣20,B可以什么都不做,comfirm的时候才给B增加20块。
     */
    @Compensable(confirmMethod = "confirmTransfer", cancelMethod = "cancelTransfer")
    @PostMapping("/simplified/transfer")
    public void transfer(@RequestParam String sourceAcctId, @RequestParam String targetAcctId, @RequestParam double amount) {
        accountService.decreaseAmount(sourceAcctId, amount); // A账户减分
        increaseAmount(targetAcctId, amount); // B账户加分
    }

    // 冻结frozen 金额
    private void increaseAmount(String acctId, double amount) {
        log.info("update tb_account_two set frozen = frozen + #{amount} where acct_id = #{acctId}");
        int value = transferDao.increaseAmount(acctId, amount);
        if (value != 1) {
            throw new IllegalStateException("ERROR!");
        }
        System.out.printf("执行加分: acct= %s, amount=%s", acctId, amount);
    }

    // 扣除冻结frozen 金额
    public void confirmTransfer(String sourceAcctId, String targetAcctId, double amount) {
        log.info("update tb_account_two set amount = amount + #{amount}, frozen = frozen - #{amount} where acct_id = #{acctId}");
        int value = transferDao.confirmIncrease(targetAcctId, amount);
        if (value != 1) {
            throw new IllegalStateException("ERROR!");
        }
        System.out.printf("提交加分: acct= %s, amount= %s", targetAcctId, amount);
    }

    // 取消冻结frozen 金额
    public void cancelTransfer(String sourceAcctId, String targetAcctId, double amount) {
        log.info("update tb_account_two set frozen = frozen - #{amount} where acct_id = #{acctId}");
        int value = transferDao.cancelIncrease(targetAcctId, amount);
        if (value != 1) {
            throw new IllegalStateException("ERROR!");
        }
        System.out.printf("取消加分: acct= %s, amount= %s", targetAcctId, amount);
    }
}
package org.example.fenbushi.tcc;

//import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

//@FeignClient("account-service")

// 通过 Feign 客户端调用和 TCC-Transaction 框架的协调,两个不同的服务实现了分布式事务的 try、confirm 和 cancel 操作。
// 每个服务在自己的范围内处理自己的事务,确保在分布式环境下的数据一致性。
public interface AccountServiceClient extends IAccountService {

    @Override
    @PostMapping("/decrease")
    void decreaseAmount(@RequestParam("acctId") String acctId, @RequestParam("amount") double amount);
}
package org.example.fenbushi.tcc;

import org.mengyun.tcctransaction.api.Compensable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;

@RestController
public class AccountServiceImpl implements IAccountService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 在 TCC-Transaction 中,@Compensable 注解本身已经包含了事务管理的功能,因此不需要再单独加上 @Transactional 注解。
     * @Compensable 注解会确保 try、confirm 和 cancel 方法在事务上下文中执行。
     */
    @Compensable(confirmMethod = "decreaseAmountConfirm", cancelMethod = "decreaseAmountCancel")
    @PostMapping("/decrease")
    @Override
    public void decreaseAmount(@RequestParam("acctId") String acctId, @RequestParam("amount") double amount) {
        int value = jdbcTemplate.update("update tb_account_one set amount = amount-?, frozen = frozen+? where acct_id = ?",amount, amount, acctId);
        if (value != 1) {
            throw new IllegalStateException("ERROR!");
        }
        System.out.printf("exec decrease: account= %s, amount= %s", acctId, amount);
    }

    public void decreaseAmountConfirm(@RequestParam("acctId") String acctId, @RequestParam("amount") double amount) {
        int value = jdbcTemplate.update("update tb_account_one set frozen = frozen-? where acct_id = ?", amount, acctId);
        if (value != 1) {
            throw new IllegalStateException("ERROR!");
        }
        System.out.printf("done decrease: account= %s, amount= %s", acctId, amount);
    }

    public void decreaseAmountCancel(@RequestParam("acctId") String acctId, @RequestParam("amount") double amount) {
        int value = jdbcTemplate.update("update tb_account_one set amount = amount+?, frozen = frozen-? where acct_id = ?", amount, amount, acctId);
        if (value != 1) {
            throw new IllegalStateException("ERROR!");
        }
        System.out.printf("undo decrease: account= %s, amount= %s", acctId, amount);
    }
}
package org.example.fenbushi.tcc;

//import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

//@FeignClient("account-service")

// 通过 Feign 客户端调用和 TCC-Transaction 框架的协调,两个不同的服务实现了分布式事务的 try、confirm 和 cancel 操作。
// 每个服务在自己的范围内处理自己的事务,确保在分布式环境下的数据一致性。
public interface AccountServiceClient extends IAccountService {

    @Override
    @PostMapping("/decrease")
    void decreaseAmount(@RequestParam("acctId") String acctId, @RequestParam("amount") double amount);
}
package org.example.fenbushi.tcc;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class TransferDao {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public int increaseAmount(String acctId, double amount) {
        String sql = "UPDATE tb_account_two SET frozen = frozen + ? WHERE acct_id = ?";
        return jdbcTemplate.update(sql, amount, acctId);
    }

    public int confirmIncrease(String acctId, double amount) {
        String sql = "UPDATE tb_account_two SET amount = amount + ?, frozen = frozen - ? WHERE acct_id = ?";
        return jdbcTemplate.update(sql, amount, amount, acctId);
    }

    public int cancelIncrease(String acctId, double amount) {
        String sql = "UPDATE tb_account_two SET frozen = frozen - ? WHERE acct_id = ?";
        return jdbcTemplate.update(sql, amount, acctId);
    }
}
 
四、可靠消息表。

<<可靠消息最终一致性>>(Reliable Messaging for Eventual Consistency)是一种用于分布式系统中确保数据最终一致性的方法。
以下是一个可靠消息最终一致性方案的基本步骤和实现思路:

    1.消息生产者发送消息: 生产者(Producer)在执行本地事务之前,先生成一条预发送消息并将其存储在消息中间件中(如Kafka、RabbitMQ、RocketMQ等),
        但消息处于“待确认”状态。
    
    2.本地事务执行: 生产者执行本地事务操作,如数据库更新、文件操作等。如果本地事务执行失败,则需要回滚预发送消息,防止消息被实际发送。
    
    3.确认消息:如果本地事务执行成功,生产者发送一个确认消息(Confirm Message)给消息中间件,标记消息为“可发送”状态。此时,
        消息中间件将消息正式发送给消费者(Consumer)。
    
    4.消费者处理消息: 消费者接收到消息后,执行对应的业务逻辑。如果消费者处理成功,则发送一个ACK确认给消息中间件。如果处理失败,
        消费者可以选择重试或执行其他补偿措施。
    
    5.消息状态检查和补偿: 生产者和消费者都需要具备失败重试机制。如果生产者发送确认消息失败,或者消费者处理消息失败,需要有定期检查和补偿机制,
        确保最终一致性。
package org.example.fenbushi.kekaoxiaoxi;

import org.apache.rocketmq.client.producer.TransactionSendResult;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;

/**
 * 1.生成预发送消息
 * 在 MessageService 中实现生成预发送消息的方法:
 */
@Service
public class MessageService {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    // 生成预发送消息
    public TransactionSendResult prepareMessage(String topic, String messageId, String messageContent) {
        Message<String> message = MessageBuilder.withPayload(messageContent)
                .setHeader(RocketMQHeaders.KEYS, messageId)
                .build();
        return rocketMQTemplate.sendMessageInTransaction(topic, message, null);
    }

    // 确认消息(RocketMQ事务消息由事务监听器处理)
    public void confirmMessage(String messageId) {
        // RocketMQ事务消息不需要显式确认
    }

    // 获取待确认消息(RocketMQ事务消息由事务监听器处理)
    public String getPendingMessage(String messageId) {
        // RocketMQ事务消息不需要显式获取待确认消息
        return null;
    }

    // 确认消息已经被处理(RocketMQ事务消息自动确认)
    public void acknowledgeMessage(String messageId) {
        // RocketMQ事务消息不需要显式确认
    }
}
package org.example.fenbushi.kekaoxiaoxi;

import org.apache.rocketmq.client.producer.LocalTransactionState;
import org.apache.rocketmq.client.producer.TransactionSendResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 2. 生产者事务操作
 * 在 ProducerService 中实现事务操作:
 */
@Service
public class ProducerService {

    @Autowired
    private MessageService messageService;

    /**
     * 当生产者调用 processAndSendMessage 方法时,首先会执行 messageService.prepareMessage 方法,该方法发送预发送消息到RocketMQ中间件,并且会立即返回 TransactionSendResult 对象。
     * 接着,RocketMQ中间件会回调事务监听器的 executeLocalTransaction 方法,在该方法中执行生产者的本地事务操作。
     * 如果 executeLocalTransaction 方法返回 COMMIT,则生产者在 processAndSendMessage 方法中会进入 if (result.getLocalTransactionState() == LocalTransactionState.COMMIT_MESSAGE) 分支,执行本地事务的后续逻辑,如数据库更新等。
     */
    @Transactional
    public void processAndSendMessage(String topic, String messageId, String messageContent) {
        try {
            // 步骤1.生成预发送消息
            TransactionSendResult result = messageService.prepareMessage(topic, messageId, messageContent);

            // 步骤5.  根据预发送消息的结果执行本地事务操作
            if (result.getLocalTransactionState() == LocalTransactionState.COMMIT_MESSAGE) {
                // 本地事务操作
                // ... 执行本地事务,例如数据库更新,适合需要立即确认事务执行结果和对消息状态有直接影响的场景。
            }else {
                // 步骤6. 如果本地事务执行失败或无法确定消息状态,可能需要进行补偿或处理
            }
        } catch (Exception e) {
            // 事务回滚
            throw new RuntimeException("Transaction failed", e);
        }
    }
}
package org.example.fenbushi.kekaoxiaoxi;

import org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionState;
import org.springframework.messaging.Message;
import org.springframework.stereotype.Component;

/**
 * 3. 实现事务监听器
 * 为了处理事务消息的确认和回查,需实现RocketMQ的事务监听器:
 */
@Component
public class TransactionListenerImpl implements RocketMQLocalTransactionListener {

    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object arg) {
        // 步骤2.
        // 执行本地事务,如数据库操作等
        try {
            // 模拟本地事务操作。 这种方式适合于那些本地事务执行时间较长,或者需要在发送消息后有更多逻辑处理的场景。

            // 步骤3. 如果成功返回COMMIT,失败返回ROLLBACK,未知状态返回UNKNOWN
            return RocketMQLocalTransactionState.COMMIT;
        } catch (Exception e) {
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }

    /**
     * 在RocketMQ的事务消息机制中,checkLocalTransaction 方法是由RocketMQ中间件调用的。具体的调用时机和流程如下:
     * 执行本地事务后的确认阶段:
     * 1. 当生产者的 executeLocalTransaction 方法返回 RocketMQLocalTransactionState.COMMIT 或 RocketMQLocalTransactionState.ROLLBACK 后,RocketMQ中间件会记录下事务执行的结果。
     * 2. RocketMQ中间件会启动一个定时任务,周期性地检查未完成的事务消息的状态。对于每条未完成的事务消息,RocketMQ会调用生产者注册的 checkLocalTransaction 方法来查询本地事务的最终状态。
     * 3. 返回事务状态:生产者的 checkLocalTransaction 方法实现中,根据实际情况检查本地事务的状态。
     *      如果本地事务已经提交完成,则返回 RocketMQLocalTransactionState.COMMIT。
     *      如果本地事务仍然未完成或者处理失败,则返回 RocketMQLocalTransactionState.UNKNOWN。
     *      处理未完成的事务消息:
     *
     * 4. 根据 checkLocalTransaction 返回的状态,RocketMQ中间件会进一步处理未完成的事务消息。
     *      如果返回 COMMIT,RocketMQ将提交预发送消息,允许消费者正常接收和处理消息。
     *      如果返回 UNKNOWN,RocketMQ可能会根据具体的策略继续等待或者进行其他处理。
     *
     * 因此,checkLocalTransaction 方法的主要作用是在RocketMQ中间件确认事务消息状态时被调用,用于通知RocketMQ当前事务的最终状态,以便RocketMQ能够正确地处理和投递消息。
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
        // 步骤4.
        // 检查本地事务状态,如果消息已经处理完成可以返回COMMIT,否则返回UNKNOWN
        // ... 检查本地事务状态逻辑
        return RocketMQLocalTransactionState.COMMIT;
    }
}
package org.example.fenbushi.kekaoxiaoxi;// 1. RocketMQConsumer.java

import io.netty.channel.ChannelOutboundBuffer;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * 4.消费者处理消息
 * 使用RocketMQ监听器来处理消息:
 */
@Slf4j
@Component
public class RocketMQConsumer {

    @Autowired
    private ChannelOutboundBuffer.MessageProcessor messageProcessor; // 实际处理消息的业务逻辑

    @RocketMQMessageListener(topic = "your_topic", consumerGroup = "your_consumer_group")
    public class MessageListenerImpl implements RocketMQListener<MessageExt> {

        @Override
        public void onMessage(MessageExt message) {
            try {
                // 2. 处理消息
                boolean success = messageProcessor.processMessage(message);
                
                // 5. 如果处理成功,打印日志
                if (success) {
                    log.info("Message processed successfully. Message ID: {}", message.getMsgId());
                } else {
                    // 6. 如果处理失败,抛出异常触发重试逻辑
                    throw new RuntimeException("Failed to process message. Message ID: " + message.getMsgId());
                }
            } catch (Exception e) {
                // 7. 处理异常,触发重试或补偿逻辑
                handleException(message, e);
            }
        }

        // 8. 处理异常的方法
        private void handleException(MessageExt message, Exception e) {
            int reconsumeTimes = message.getReconsumeTimes(); // 获取重试次数
            log.error("Failed to process message for {} times. Message ID: {}", reconsumeTimes, message.getMsgId(), e);

            // 9. 根据重试次数进行判断,超过一定次数则记录日志或者进行补偿处理
            if (reconsumeTimes >= 3) {
                log.error("Message processing failed after {} retries. Message ID: {}", reconsumeTimes, message.getMsgId());
                // 可以记录日志或进行补偿处理,如发送告警通知等
            } else {
                // 10. 重试逻辑:抛出异常,RocketMQ会自动进行重试,即重新消费消息
                throw new RuntimeException("Failed to process message. Retry: " + reconsumeTimes);
            }
        }
    }
}

 

package org.example.fenbushi.kekaoxiaoxi;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * 5. 定期检查和补偿机制
 * 可以通过定时任务检查未确认和未处理的消息,并进行相应的补偿处理,确保消息最终一致性。
 */
@Component
public class CompensationTask {
    
    @Autowired
    private MessageService messageService;
    
    @Scheduled(fixedRate = 60000)
    public void checkPendingMessages() {
        // 检查并处理未确认的消息
        // ...
        
        // 检查并处理未处理的消息
        // ...
    }
}

五、本地消息表


* 3.2 本地消息表
*     A系统在自己本地一个事务里操作同时,插入一条数据到消息表(同一个数据库--原子性)
*     接着A系统将这个消息发送到MQ中去
*     B系统接收到消息之后,在一个事务里,往自己本地消息表里(幂等性)插入一条数据,同时执行其他的业务操作,如果这个消息已经被处理过了,那么此时这个事务会回滚,这样保证不会重复处理消息
*     B系统执行成功之后,就会更新自己本地消息表的状态 以及A系统消息表的状态(向A服务发送确认请求, 调用A服务接口)
*     如果B系统处理失败了,那么就不会更新消息表状态,那么此时A系统会定时扫描自己的消息表,如果有没处理的消息,会再次发送到MQ中去,让B再次处理
*     这个方案保证了最终一致性,哪怕B事务失败了,但是A会不断重发消息,直到B那边成功为止,达到最终一致
*     我们发送出去的消息,必须得带上业务相关联的数据,例如新增用户,发送增加积分mq消息,我们的mq消息的消息体得带上新增这个用户的id,以便以后回溯查找问题。
* 
*     简单理解:消息本地化,定时任务定时发送消息中间件。


* 3.3 步骤如下:
*     1. 事务内写入本地消息表
*     2. 定时任务扫描本地消息表
*     3. 发送消息到消息队列
*     4. 重试机制


3.3 总结
*     本地消息表模式通过在本地数据库中记录需要发送的消息,并使用定时任务定期扫描和发送,确保在分布式系统中消息能够最终被处理。关键点在于:
*     1.确保业务数据和消息记录在同一个事务中提交。
*     2.使用定时任务扫描和发送消息。
*     3.设置重试机制,处理发送失败的情况。
*     4.这种模式能够有效解决分布式事务中的数据一致性问题,保证消息的可靠传递。并且记录本地事务日志表状态。

3.4 详细步骤:
*     1. A 服务:发送消息
         A 服务在事务内写入业务数据和本地消息表,并通过定时任务或其他方式发送消息到消息队列。

*     2. B 服务:消费消息并处理业务逻辑
         B 服务从消息队列消费消息,并处理相应的业务逻辑。
* 
*     3. A 服务:处理确认请求
         A 服务收到确认请求后,更新本地消息表中的消息状态,标记为已处理。
* 
*     4. 清理本地消息表
         A 服务可以定期清理本地消息表中状态为“ACKNOWLEDGED”的消息,以避免消息表无限增长。
* 
      总结
      A 服务:写入业务数据和本地消息表,并发送消息到消息队列。
      B 服务:从消息队列消费消息,处理业务逻辑,并向 A 服务发送确认请求。
      A 服务:收到确认请求后,更新本地消息表中的消息状态为已确认。
      清理机制:A 服务定期清理已确认的消息,保持消息表的简洁。这种模式确保了消息的可靠传递和处理,通过确认机制避免了重复消费和数据不一致的问题。
package org.example.fenbushi.bendixiaoxibiao;

import lombok.extern.slf4j.Slf4j;
import org.example.dao.BO.LocalMessage;
import org.example.dao.mapper.UserInfoMapper;
import org.example.repository.LocalMessageRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import java.util.List;

@Slf4j
public class BenDiXiaoXiDemo {

    @Autowired
    private UserInfoMapper userInfoMapper;
    @Autowired
    private LocalMessageRepository localMessageRepository;

    /**
     * 1. A 服务:发送消息
     * A 服务在事务内写入业务数据和本地消息表,并通过定时任务或其他方式发送消息到消息队列。
     */
    @Transactional
    public void executeBusinessLogicAndSendMessage(String businessData, String messageData) {
        // 执行业务逻辑
        userInfoMapper.save(businessData);

        // 写入本地消息表
        LocalMessage localMessage = new LocalMessage();
        localMessage.setMessageData(messageData);
        localMessage.setStatus("NEW"); // 标记消息为新建状态
        localMessageRepository.save(localMessage);

        // 事务提交后,业务数据和消息记录都被保存 ...
    }

    // 定时任务扫描消息表并发送消息
    @Scheduled(fixedRate = 5000)
    void processLocalMessages() {
        List<LocalMessage> newMessages = localMessageRepository.findByStatus("NEW");
        for (LocalMessage message : newMessages) {
            try {
                //sendToMessageQueue(message);
                message.setStatus("SENT");
                localMessageRepository.save(message);
            } catch (Exception e) {
                log.error("Failed to send message", e);
            }
        }
    }

    /**
     * 2. B 服务:消费消息并处理业务逻辑
     * B 服务从消息队列消费消息,并处理相应的业务逻辑。
     */
    class MessageListener {
        @Autowired
        private RestTemplate restTemplate; // 用于发送确认请求

        //@RabbitListener(queues = "queueName")
        public void handleMessage(String messageData) {
            try {
                // 处理业务逻辑
                processBusinessLogic(messageData);

                // 向A服务发送确认请求
                sendAcknowledgment(messageData);
            } catch (Exception e) {
                // 处理消费失败的情况
                log.error("Failed to process message", e);
            }
        }

        private void processBusinessLogic(String messageData) {
            // B服务的业务逻辑处理
        }

        private void sendAcknowledgment(String messageId) {
            String aServiceUrl = "http://A-service/acknowledge";
            Acknowledgment ack = new Acknowledgment(messageId);
            //可以使用dubbo服務調用. //不一定需要消费者发送确认消息回生产者。
            restTemplate.postForEntity(aServiceUrl, ack, Void.class);
        }
    }

    /**
     * 3. A 服务:处理确认请求
     * A 服务收到确认请求后,更新本地消息表中的消息状态,标记为已处理。
     */
    @RestController
    public class AcknowledgmentController {
        @Autowired
        private LocalMessageRepository localMessageRepository;

        @PostMapping("/acknowledge")
        public ResponseEntity<Void> acknowledgeMessage(@RequestBody Acknowledgment ack) {
            LocalMessage localMessage = localMessageRepository.findById(ack.getMessageId());
            if (localMessage != null) {
                localMessage.setStatus("ACKNOWLEDGED"); // 更新消息状态为已确认
                localMessageRepository.save(localMessage);
            }
            return ResponseEntity.ok().build();
        }
    }

    public class Acknowledgment {
        private String messageId;

        public Acknowledgment(String messageId) {
            this.messageId = messageId;
        }

        public String getMessageId() {
            return messageId;
        }

        public void setMessageId(String messageId) {
            this.messageId = messageId;
        }
    }

    /**
     * 4. 清理本地消息表
     * A 服务可以定期清理本地消息表中状态为“ACKNOWLEDGED”的消息,以避免消息表无限增长。
     */
    @Scheduled(fixedRate = 86400000) // 每天执行一次
    public void cleanAcknowledgedMessages() {
        //localMessageRepository.deleteByStatus("ACKNOWLEDGED");
    }


}
package org.example.fenbushi.bendixiaoxibiao;

import lombok.extern.slf4j.Slf4j;
import org.example.dao.BO.LocalMessage;
import org.example.dao.mapper.UserInfoMapper;
import org.example.repository.LocalMessageRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import java.util.List;

@Slf4j
public class BenDiXiaoXiDemo {

    @Autowired
    private UserInfoMapper userInfoMapper;
    @Autowired
    private LocalMessageRepository localMessageRepository;

    /**
     * 1. A 服务:发送消息
     * A 服务在事务内写入业务数据和本地消息表,并通过定时任务或其他方式发送消息到消息队列。
     */
    @Transactional
    public void executeBusinessLogicAndSendMessage(String businessData, String messageData) {
        // 执行业务逻辑
        userInfoMapper.save(businessData);

        // 写入本地消息表
        LocalMessage localMessage = new LocalMessage();
        localMessage.setMessageData(messageData);
        localMessage.setStatus("NEW"); // 标记消息为新建状态
        localMessageRepository.save(localMessage);

        // 事务提交后,业务数据和消息记录都被保存 ...
    }

    // 定时任务扫描消息表并发送消息
    @Scheduled(fixedRate = 5000)
    void processLocalMessages() {
        List<LocalMessage> newMessages = localMessageRepository.findByStatus("NEW");
        for (LocalMessage message : newMessages) {
            try {
                //sendToMessageQueue(message);
                message.setStatus("SENT");
                localMessageRepository.save(message);
            } catch (Exception e) {
                log.error("Failed to send message", e);
            }
        }
    }

    /**
     * 2. B 服务:消费消息并处理业务逻辑
     * B 服务从消息队列消费消息,并处理相应的业务逻辑。
     */
    class MessageListener {
        @Autowired
        private RestTemplate restTemplate; // 用于发送确认请求

        //@RabbitListener(queues = "queueName")
        public void handleMessage(String messageData) {
            try {
                // 处理业务逻辑
                processBusinessLogic(messageData);

                // 向A服务发送确认请求
                sendAcknowledgment(messageData);
            } catch (Exception e) {
                // 处理消费失败的情况
                log.error("Failed to process message", e);
            }
        }

        private void processBusinessLogic(String messageData) {
            // B服务的业务逻辑处理
        }

        private void sendAcknowledgment(String messageId) {
            String aServiceUrl = "http://A-service/acknowledge";
            Acknowledgment ack = new Acknowledgment(messageId);
            //可以使用dubbo服務調用. //不一定需要消费者发送确认消息回生产者。
            restTemplate.postForEntity(aServiceUrl, ack, Void.class);
        }
    }

    /**
     * 3. A 服务:处理确认请求
     * A 服务收到确认请求后,更新本地消息表中的消息状态,标记为已处理。
     */
    @RestController
    public class AcknowledgmentController {
        @Autowired
        private LocalMessageRepository localMessageRepository;

        @PostMapping("/acknowledge")
        public ResponseEntity<Void> acknowledgeMessage(@RequestBody Acknowledgment ack) {
            LocalMessage localMessage = localMessageRepository.findById(ack.getMessageId());
            if (localMessage != null) {
                localMessage.setStatus("ACKNOWLEDGED"); // 更新消息状态为已确认
                localMessageRepository.save(localMessage);
            }
            return ResponseEntity.ok().build();
        }
    }

    public class Acknowledgment {
        private String messageId;

        public Acknowledgment(String messageId) {
            this.messageId = messageId;
        }

        public String getMessageId() {
            return messageId;
        }

        public void setMessageId(String messageId) {
            this.messageId = messageId;
        }
    }

    /**
     * 4. 清理本地消息表
     * A 服务可以定期清理本地消息表中状态为“ACKNOWLEDGED”的消息,以避免消息表无限增长。
     */
    @Scheduled(fixedRate = 86400000) // 每天执行一次
    public void cleanAcknowledgedMessages() {
        //localMessageRepository.deleteByStatus("ACKNOWLEDGED");
    }


}
package org.example.dao.BO;

import lombok.Data;

@Data
public class LocalMessage {

    String messageData;
    String status;
}
package org.example.dao.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.example.dao.pojo.UserInfoPO;
import org.example.vo.User;

@Mapper
public interface UserInfoMapper extends BaseMapper<UserInfoPO> {

    User findUserById(@Param("userId") String userId);

    void save(String businessData);

}

六:最大努力通知

3.4.1 最大努力通知实现方案
    由于这里的代码比较简单,就是简化版的可靠消息,这里就不写了,只列出实现方案。

    本方案是利用MQ 的 ack 机制由 MQ 向接收通知方发送通知,流程如下:
        1. 发起方将通知发给 MQ。注意:如果消息没有发出去可由接收通知方主动请求发起通知方查询业务执行结果。
       2. 接收方监听 MQ 。
       3. 接收方接收消息,业务处理完成回应 ack 。
       4. 接收方若没有回应 ack 则 MQ 会重复通知。
          MQ 会按照间隔 1min 、 5min 、 10min 、 30min 、 1h 、 2h 、 5h 、 10h 的方式,逐步拉大通知间隔  (如果 MQ 采用 rocketMq ,在 broker 中可进行配置),直到达到通知要求的时间窗口上限。
       5. 接收方可通过消息校对接口来校对消息的一致性。
       6. 接收方定时任务去查询一段时间未完成的业务,调用发送方的查询接口。

       7. 一般来说这种方案都是我们和第三方去交互才使用的,如果第三方调用我们,我们就去实现最大努力通知服务,不停回调第三方结果,直到最大次数。如果我们去调用微信,则微信需要实现最大努力通知。
       8. 内部调用也可以这样子,一般用于一些不太重要的通知。

3.4.2 最大努力通知和可靠性消息的异同
*       1.解决方案思想不同
* 
*     --可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。
*     -- 最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
*       
*     2. 两者的业务应用场景不同
*       可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。
*       最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。
* 
*     3. 技术解决方向不同
*       可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。
*       最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)。

最后附上pom文件,有些是必须要的,有些可以不要(欢迎留言讨论!):

<?xml version="1.0" encoding="UTF-8"?>
<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>org.example</groupId>
        <artifactId>demo-IdeaC</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>idea-service</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- spring-boot启动相关依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>3.2.2</version>
        </dependency>

        <!-- 依赖子模块 -->
        <dependency>
            <artifactId>idea-stub</artifactId>
            <groupId>org.example</groupId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <!-- lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.30</version>
        </dependency>

        <!-- mybatis-plus 相关依赖-->
        <!-- 原来mybatis plus的包都删除,替换成以下两个 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.5</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-extension</artifactId>
            <version>3.5.5</version>
        </dependency>

        <!-- mysql连接 相关依赖-->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.3.0</version>
        </dependency>

        <!--skywalking-->
        <dependency>
            <groupId>org.apache.skywalking</groupId>
            <artifactId>apm-toolkit-logback-1.x</artifactId>
            <version>8.10.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.skywalking</groupId>
            <artifactId>apm-toolkit-trace</artifactId>
            <version>8.10.0</version>
        </dependency>

        <!--springboot-web actuator、prometheus监控接口-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-core</artifactId>
        </dependency>
        <!-- end  -->

        <!--redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <artifactId>lettuce-core</artifactId>
                    <groupId>io.lettuce</groupId>
                </exclusion>
            </exclusions>
        </dependency>

        <!--spring切面aop依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
            <version>3.2.2</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
        </dependency>

        <!-- TCC-Transaction 核心库 -->
        <dependency>
            <groupId>org.mengyun</groupId>
            <artifactId>tcc-transaction-core</artifactId>
            <version>2.1.0</version>
        </dependency>

        <!-- TCC-Transaction Spring 集成 -->
        <dependency>
            <groupId>org.mengyun</groupId>
            <artifactId>tcc-transaction-spring</artifactId>
            <version>2.1.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
            <version>2.1.8.RELEASE</version>
        </dependency>

        <!--Kafka不直接支持“待确认”消息的概念,但是RocketMQ确实支持。为了实现你的需求,下面提供一个基于RocketMQ的可靠消息最终一致性方案示例。-->
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>2.2.0</version>
        </dependency>

    </dependencies>

    <!-- 默认是继承父工程的,子项目在继承父项目时就可以直接使用父项目中定义的插件,而不需要显式地在子项目中再次引入插件。 plugin-->
<!--    <build>-->
<!--        <plugins>-->
<!--            <plugin>-->
<!--                <groupId>org.springframework.boot</groupId>-->
<!--                <artifactId>spring-boot-maven-plugin</artifactId>-->
<!--            </plugin>-->
<!--            <plugin>-->
<!--                <groupId>org.apache.maven.plugins</groupId>-->
<!--                <artifactId>maven-compiler-plugin</artifactId>-->
<!--            </plugin>-->
<!--        </plugins>-->
<!--    </build>-->

</project>

 

posted @ 2024-06-13 15:35  威兰达  阅读(309)  评论(0编辑  收藏  举报