随着springcloud使用的越来越普遍,微服务也趋向于成熟,既然都分成微服务了,势必也会是分库的设方式,既然分库了,肯定会遇到分布式事务的问题,这是任何一个微服务架构设计当中逃不掉的拦路虎。关于分布式事务,网上有很多讨论,也有很多解决方案,但他们都有一个共同的缺点,就是侵入式开发,而且使用起来,也过于复杂,和业务不解偶。

        本方案使用起来简单易懂,和业务解耦、业务层面不需要关心分布式事务的问题(本方案已经生产系统当中大规模使用,如有不懂之处,可以v:hekf520)

1、先来看两张图,(1)在发起事务阶段,订单系统调用库存系统,先就行预减库存,预减成功,订单就往下走,如果预减失败,就中止订单生成。库存系统处调用pushMessage方法,通知【事务控制系统】介入。针对业务层面来说,工作已做完,后面的事由【事务控制系统】定时调用【订单系统】获取订单生成是否成功,

(a)如果订单生成功,就通知【库存系统】确认减掉库存。

(b)如果订单生失败或未生成,就通知【库存系统】取消预减。

(c)如果订单未支付,就过一段时间查询订单状态,根据状态,再通知【库存系统】

(d)定时查询,我们用到了RabbitMQ的延时队列,下一次等待时间为等比数列公式计算而来,公式为:5(秒)x((2的n次方)-1),比如订单有效期为900秒(15分钟),那么等待查询的时间为5、15、35、75、155、315、635、900


分布式事务终极解决方案_分布式

发起事务

 


分布式事务终极解决方案_分布式_02

事务控制

2、创建一个test的表,用于测试,我们只模拟上面图片带红色字部分(为了测试方便,将三个系统的相关接口放在了事务控制系统里暂做简单测试):flag1字段代表【订单系统】生成订单的状态,flag2代表【库存系统】减库存的状态(flag1和flag2具体怎么样,在项目中根据自己的业务来,这里只是模拟)

CREATE TABLE `test` (
  `id` char(32) NOT NULL COMMENT '记录id',
  `flag1` int(1) NOT NULL COMMENT 'url1的flag 0初始 1成功 2重试 3重试失败,取消',
  `flag2` int(32) NOT NULL COMMENT 'url2的flag 根据flag1的结果来',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='测试记录';

分布式事务终极解决方案_SpringCloud事务_03

另外两个表用于记录分布式事务相关的日志

CREATE TABLE `record` (
  `record_id` char(32) NOT NULL COMMENT '记录id',
  `pk1` varchar(32) NOT NULL COMMENT 'url1的主键',
  `pk2` varchar(32) NOT NULL COMMENT 'url2的主键',
  `type` int(1) NOT NULL COMMENT '类型',
  `flag` int(1) NOT NULL DEFAULT '0' COMMENT '处理标志 0初始 1成功 2重试 3重试失败,取消',
  `times` int(1) NOT NULL DEFAULT '0' COMMENT '处理次数',
  `max_expiration` int(1) NOT NULL COMMENT '最大超时秒',
  `remark` varchar(255) DEFAULT NULL COMMENT '备注',
  `url1` varchar(1024) NOT NULL COMMENT '请求地址一',
  `url2` varchar(1024) NOT NULL COMMENT '请求地址二',
  `creater` char(16) NOT NULL COMMENT '创建人',
  `updater` char(16) NOT NULL COMMENT '更新人',
  `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`record_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='事务记录';


CREATE TABLE `record_log` (
  `log_id` char(32) NOT NULL COMMENT '日志id',
  `record_id` char(32) NOT NULL COMMENT '记录id',
  `state` int(1) NOT NULL DEFAULT '0' COMMENT '处理标志 0初始 1成功 2重试 3重试失败,取消',
  `url1` varchar(1024) NOT NULL COMMENT '请求地址一',
  `url2` varchar(1024) NOT NULL COMMENT '请求地址二',
  `content` varchar(512) DEFAULT NULL COMMENT '内容',
  `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`log_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='事务记录日志';

3、运行transation项目的TransApplication启动起来,访问 http://localhost:8001/trans/swagger-ui.html,模拟上面图1红色字部分

分布式事务终极解决方案_分布分布式事务_04

分布式事务终极解决方案_SpringCloud事务_05

4、这时我们来看RabbitMQ上面的情况

分布式事务终极解决方案_分布式事务_06

分布式事务终极解决方案_分布式事务_07

4、我们看一下关键的几个类,RabbitMq的

@Configuration
public class DelayedRabbitMQConfig {
    private static final String active="my";
    // 延迟队列 TTL 名称
    private static final String DELAY_QUEUE = active+".delay.queue";
    // DLX,dead letter发送到的 exchange
    // 延时消息就是发送到该交换机的
    public static final String DELAY_EXCHANGE = active+".delay.exchange";
    // routing key 名称
    // 具体消息发送在该 routingKey 的
    public static final String DELAY_ROUTING_KEY = active+".delay.routing.key";


    //立即消费的队列名称
    public static final String FLASH_QUEUE = active+".flash.queue";
    // 立即消费的exchange
    public static final String FLASH_EXCHANGE = active+".flash.exchange";
    //立即消费 routing key 名称
    public static final String FLASH_ROUTING_KEY = active+".flash.routing.key";




    /**
     * 创建一个延时队列
     */
    @Bean
    public Queue delayQueue() {
        Map<String, Object> params = new HashMap<>();
        // x-dead-letter-exchange 声明了队列里的死信转发到的DLX名称,
        params.put("x-dead-letter-exchange", FLASH_EXCHANGE);
        // x-dead-letter-routing-key 声明了这些死信在转发时携带的 routing-key 名称。
        params.put("x-dead-letter-routing-key", FLASH_ROUTING_KEY);
        return new Queue(DELAY_QUEUE, true, false, false, params);
    }
    /**
     * 延迟交换机
     */
    @Bean
    public DirectExchange toDirectExchange() {
        // 一共有三种构造方法,可以只传exchange的名字, 第二种,可以传exchange名字,是否支持持久化,是否可以自动删除,
        // 第三种在第二种参数上可以增加Map,Map中可以存放自定义exchange中的参数
        // new DirectExchange(ORDER_DELAY_EXCHANGE,true,false);
        return new DirectExchange(DELAY_EXCHANGE);
    }
    /**
     * 把延时队列和 订单延迟交换的exchange进行绑定
     * @return
     */
    @Bean
    public Binding delayBinding() {
        return BindingBuilder.bind(delayQueue()).to(toDirectExchange()).with(DELAY_ROUTING_KEY);
    }


    /**
     * 创建一个立即消费队列
     */
    @Bean
    public Queue flashQueue() {
        // 第一个参数为queue的名字,第二个参数为是否支持持久化
        return new Queue(FLASH_QUEUE, true);
    }
    /**
     * 立即消费交换机
     */
    @Bean
    public TopicExchange topicExchange() {
        return new TopicExchange(FLASH_EXCHANGE);
    }
    /**
     * 把立即队列和 立即交换的exchange进行绑定
     * @return
     */
    @Bean
    public Binding flashBinding() {
        // TODO 如果要让延迟队列之间有关联,这里的 routingKey 和 绑定的交换机很关键
        return BindingBuilder.bind(flashQueue()).to(topicExchange()).with(FLASH_ROUTING_KEY);
    }

}
@Component
@Slf4j
public class MQReceiverListener {
    private static Logger logger = LoggerFactory.getLogger(MQReceiverListener.class);
    @Autowired
    private RecordService recordService;

    //监听消息队列
    @RabbitListener(queues = {DelayedRabbitMQConfig.FLASH_QUEUE})
    public void consumeMessage(RecordMqVo mqVo, Message message, Channel channel) throws IOException {
        log.info("处理订阅消息开始.......");
        System.out.println(mqVo);
        boolean boo1=true;
        boolean boo2=true;
        Gson gs = new Gson();
        String errMsg=null;
        Integer flag = null;
        LinkedTreeMap transmitMap=null;
        try {
            logger.info("请求url1:"+mqVo.getUrl1());
            String str1 = HttpUtil.doPost(mqVo.getUrl1(),new HashMap());
            ResultInfoVo resultInfoVo = gs.fromJson(str1,ResultInfoVo.class);
            transmitMap = (LinkedTreeMap)resultInfoVo.getData();
            Integer data = Double.valueOf(transmitMap.get("flag").toString()).intValue();
            if(TanServerConstants.RECORD_FLAG.str1.equals(data)){
                //成功
                flag=data;
            }else if(TanServerConstants.RECORD_FLAG.str0.equals(data)||TanServerConstants.RECORD_FLAG.str2.equals(data)){
                //等待
                //再次发送消息
                mqVo.setContent("等待订单失效中...");
                messageReTry(mqVo,channel,message);
                return;
            }else if(TanServerConstants.RECORD_FLAG.str3.equals(data)){
                //取消
                flag=data;
            }
        }catch (Exception e){
            boo1=false;
            mqVo.setContent("url1请求异常:"+e.getMessage());
            messageReTry(mqVo,channel,message);
            e.printStackTrace();
            return;
        }

        if(boo1) {
            try {
                String url2=mqVo.getUrl2();
                logger.info("请求url2:"+url2);
                String str2 = HttpUtil.doPostJson(url2, new Gson().toJson(transmitMap));
                ResultInfoVo resultInfoVo = gs.fromJson(str2, ResultInfoVo.class);
                if(!ResultInfoVo.SUCCESS.equals(resultInfoVo.getCode())){
                    boo2=false;
                    mqVo.setContent("请求url2出错:"+resultInfoVo.getMessage());
                    messageReTry(mqVo,channel,message);
                    return;
                }
            } catch (Exception e) {
                boo2 = false;
                mqVo.setContent("url2请求异常:" + e.getMessage());
                messageReTry(mqVo,channel,message);
                e.printStackTrace();
            }
        }

        try {
            if(boo1&boo2){
                recordService.updateRecord(mqVo.getRecordId(), flag,1,errMsg);
            }
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            mqVo.setContent("确信信息步骤出错:"+e.getMessage());
            messageReTry(mqVo,channel,message);
            e.printStackTrace();
        }
    }

    /**
     * 信息重试
     * @param mqVo
     * @param channel
     * @param message
     */
    public void messageReTry(RecordMqVo mqVo,Channel channel,Message message){
        try {
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (IOException e) {
            e.printStackTrace();
        }
        reTry(mqVo);
        logger.info(mqVo.getContent());
    }

    /**
     * 重试
     * @param mqVo
     */
    private void reTry(RecordMqVo mqVo){
        logger.error("重试信息");
        System.out.println(mqVo);
        String maxExpirationStr=mqVo.getMaxExpirationStr();
        int times = mqVo.getTimes()+1;
        mqVo.setTimes(times);
        mqVo.setFlag(TanServerConstants.RECORD_FLAG.str2);

        int nextTime = recordService.getNextTime(times);
        String nextExpirationStr=mqVo.setNextExpiration(nextTime);


        if(maxExpirationStr.compareTo(nextExpirationStr)>=0){
            recordService.pushMessage(mqVo);
        }else{
            Date currentDate = new Date();
            String currentTimeStr = DateUtils.dateToString(currentDate,"yyyy-MM-dd HH:mm:ss");
            if(currentTimeStr.compareTo(maxExpirationStr)==1){
                //超时抛弃
                String errMsg = "超时抛弃";
                mqVo.setContent(errMsg);
                recordService.updateRecord(mqVo.getRecordId(), TanServerConstants.RECORD_FLAG.str3,1,errMsg);
            }else{
                Date maxExpirationDate = DateUtils.stringToDate(maxExpirationStr,"yyyy-MM-dd HH:mm:ss");
                System.out.println(currentDate+"  "+maxExpirationDate);
                Long milliSecond=DateUtils.plusDateMilliSecond(currentDate,maxExpirationDate);
                System.out.println("milliSecond=="+milliSecond);
                if(milliSecond.intValue()>1000) {//必须大于1秒以上
                    mqVo.setNextExpiration(milliSecond.intValue());//延缓3秒
                    recordService.pushMessage(mqVo);
                }else{
                    String errMsg = "超时抛弃";
                    mqVo.setContent(errMsg);
                    recordService.updateRecord(mqVo.getRecordId(), TanServerConstants.RECORD_FLAG.str3,1,errMsg);
                }
            }

        }
    }

}

5、看一样业务层面的几个关键类

@RestController
@RequestMapping("/record")
@Api(tags = "RecordController", description = "分布式事务记录控制器")
@Validated
public class RecordController {
    @Autowired
    private RecordService recordService;


    @PostMapping(value = "/pushMessage")
    @ApiOperation(value = "推送消息")
    public ResultInfoVo pushMessage(@RequestBody RecordMqParam param){
        RecordMqVo mqVo = new RecordMqVo();
        CommonUtils.copyPropertiesToMap(param,mqVo,true);
        Integer nextExpiration = recordService.getNextTime(1);

        mqVo.setNextExpiration(nextExpiration);
        mqVo = recordService.pushMessage(mqVo);

        return new ResultInfoVo(mqVo);
    }


    @PostMapping(value = "/getTestFlag1")
    @ApiOperation(value = "(微服务1)获取使用状态FlagTarget,其中flag获取状态,返回值为 1成功 2重试 3重试失败,取消")
    public ResultInfoVo getTestFlag1(String pk1){
        System.out.println("获取使用状态:pk1="+pk1);
        Test test = recordService.findTest();

        FlagTarget target = new FlagTarget();//FlagTarget会自动传给confirmFlag1方法
        target.setFlag(test.getFlag1());
        target.setPk1(pk1);
        target.setPk2("123456");
        target.setDesc("测试,这个字段为业务新加的字段");

        return new ResultInfoVo(target);
    }

    @PostMapping(value = "/confirmFlag1")
    @ApiOperation(value = "(微服务2)确认预减:FlagTarget由刷新系统自动传递过来")
    public ResultInfoVo confirmFlag1(@RequestBody FlagTarget target){
        System.out.println("确认预减:"+new Gson().toJson(target));
        recordService.updateTestFlag2(target.getFlag());
        return new ResultInfoVo();
    }
}
@Service
public class RecordServiceImpl implements RecordService {
    private static Logger logger = LoggerFactory.getLogger(RecordServiceImpl.class);
    @Autowired
    private RecordDao recordDao;
    @Autowired
    private RabbitTemplate rabbitTemplate;
    @Autowired
    private CommonDao commonDao;


    /**
     * 推送消息
     * @param mqVo
     */
    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public RecordMqVo pushMessage(RecordMqVo mqVo){
        System.out.println(new Gson().toJson(mqVo));
        Record record = null;
        if(StringUtils.isNotEmpty(mqVo.getRecordId())){
            record = recordDao.findTById(Record.class,mqVo.getRecordId());
            if(record!=null){
                record.setTimes(mqVo.getTimes());
                record.setFlag(mqVo.getFlag());
                RecordLog recordLog = new RecordLog();
                String id = Utils.createPrimaryKey("L",32);
                CommonUtils.copyPropertiesToMap(mqVo,recordLog,true);
                CommonUtils.copyPropertiesToMap(record,recordLog,true);
                recordLog.setLogId(id);
                if(TanServerConstants.RECORD_FLAG.str1.equals(mqVo.getFlag())){
                    recordLog.setState(TanServerConstants.YES);
                }else {
                    recordLog.setState(TanServerConstants.NO);
                }
                commonDao.saveT(recordLog);
            }else{
                logger.error("事务记录未找到,记录id为:"+mqVo.getRecordId());
            }
        }else{
            record = new Record();
            CommonUtils.copyProperties(mqVo,record,true);
            String id = Utils.createPrimaryKey("R",32);
            record.setRecordId(id);
            record.setTimes(0);
            record.setFlag(TanServerConstants.RECORD_FLAG.str0);
            record.setCreater("system");
            record.setUpdater("system");
            recordDao.saveT(record);
            CommonUtils.copyProperties(record,mqVo,true);
            mqVo.setCreatedStr(DateUtils.dateToString(new Date(),"yyyy-MM-dd HH:mm:ss"));
        }
        try {
            logger.info("发送消息:"+mqVo);
            this.rabbitTemplate.convertAndSend(DelayedRabbitMQConfig.DELAY_EXCHANGE, DelayedRabbitMQConfig.DELAY_ROUTING_KEY, mqVo, message -> {
                // 如果配置了 params.put("x-message-ttl", 5 * 1000); 那么这一句也可以省略,具体根据业务需要是声明 Queue 的时候就指定好延迟时间还是在发送自己控制时间
                message.getMessageProperties().setExpiration(mqVo.getNextExpiration().toString());
                return message;
            });
        } catch (Exception e) {
            logger.error("向mq中发送消息,出现异常:"+e.getMessage());
            e.printStackTrace();
        }
        return mqVo;
    }


    /**
     * 修改状态
     */
    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public void updateRecord(String recordId,Integer flag,Integer times,String content) {
        Record record = null;
        if (StringUtils.isNotEmpty(recordId)) {
            record = recordDao.findTById(Record.class, recordId);
            if (record != null) {
                if(times!=null) {
                    record.setTimes(record.getTimes()+times);
                }
                if(flag!=null){
                    record.setFlag(flag);
                }
                RecordLog recordLog = new RecordLog();
                String id = Utils.createPrimaryKey("L",32);
                CommonUtils.copyPropertiesToMap(record,recordLog,true);
                recordLog.setLogId(id);
                if(TanServerConstants.RECORD_FLAG.str1.equals(flag)){
                    recordLog.setState(TanServerConstants.YES);
                }else {
                    recordLog.setState(TanServerConstants.NO);
                }
                recordLog.setContent(content);
                commonDao.saveT(recordLog);
            } else {
                logger.error("更新事务记录,事务记录未找到,记录id为:" + recordId);
            }
        }
    }

    /**
     * 下次超时时间
     * @param time
     * @return
     */
    public Integer getNextTime(Integer time){
        Double result = 5000*(Math.pow(2.0,time)-1);
        return result.intValue();
    }

    /**
     * 下次超时运行时间
     * @param millisecond
     * @return
     */
    public String getNextTimeString(Integer millisecond,String maxExpirationStr){
        String nextTimeStr;
       Date date = new Date();
       System.out.println(date);
       Date nextDate = DateUtils.plusSeconds(date,millisecond/1000);
       String nextDateStr = DateUtils.dateToString(nextDate,"yyyy-MM-dd HH:mm:ss");
       if(nextDateStr.compareTo(maxExpirationStr)==1){
           nextTimeStr= maxExpirationStr;
       }else{
           nextTimeStr= nextDateStr;
       }
       System.out.println("nextTimeStr=="+nextTimeStr);
       return nextTimeStr;
    }


    public Test findTest(){
        Test test  = (Test)commonDao.findTById(Test.class,"1");
        return test;
    }

    @Transactional
    public void updateTestFlag2(Integer flag2){
        Test test  = (Test)commonDao.findTById(Test.class,"1");
        test.setFlag2(flag2);
    }
}