点击上方 "程序员小乐"关注, 星标或置顶一起成长
每天凌晨00点00分, 第一时间与你相约
每日英文
To live a beautiful life, one must be tolerant, without complaint or explanation.
要生活得漂亮,需要付出极大忍耐,一不抱怨,二不解释。
每日掏心话
人生之所以精彩,是他愿意全然的接受一切。生命之所以可贵,是他愿意尊重一切的生命。
来自:黄哥 | 责编:乐乐
程序员小乐(ID:study_tech) 第 908 次推文 图源:百度往日回顾:“微信支付”的架构到底有多牛逼?
正文
前提
分布式事务是微服务实践中一个比较棘手的问题,在笔者所实施的微服务实践方案中,都采用了折中或者规避强一致性的方案。参考Ebay多年前提出的本地消息表方案,基于RabbitMQ和MySQL(JDBC)做了轻量级的封装,实现了低入侵性的事务消息模块。本文的内容就是详细分析整个方案的设计思路和实施。环境依赖如下:
JDK1.8+
spring-boot-start-web:2.x.x
spring-boot-start-jdbc:2.x.x
spring-boot-start-amqp:2.x.x
HikariCP:3.x.x(spring-boot-start-jdbc自带)
mysql-connector-java:5.1.48
redisson:3.12.1
方案设计思路
事务消息原则上只适合弱一致性(或者说最终一致性)的场景,常见的弱一致性场景如:
用户服务完成了注册动作,向短信服务推送一条营销相关的消息。
信贷体系中,订单服务保存订单完毕,向审批服务推送一条待审批的订单记录信息。
……
强一致性的场景一般不应该选用事务消息。
一般情况下,要求强一致性说明要严格同步,也就是所有操作必须同时成功或者同时失败,这样就会引入同步带来的额外消耗。
如果一个事务消息模块设计合理,补偿、查询、监控等等功能都完毕,由于系统交互是异步的,整体吞吐要比严格同步高。在笔者负责的业务系统中基于事务消息使用还定制了一条基本原则:消息内容正确的前提下,消费方出现异常需要自理。
简单来说就是:上游保证了自身的业务正确性,成功推送了正确的消息到RabbitMQ就认为上游义务已经结束。
为了降低代码的入侵性,事务消息需要借助Spring的编程式事务或者声明式事务。编程式事务一般依赖于TransactionTemplate,而声明式事务依托于AOP模块,依赖于注解@Transactional。
接着需要自定义一个事务消息功能模块,新增一个事务消息记录表(其实就是本地消息表),用于保存每一条需要发送的消息记录。事务消息功能模块的主要功能是:
保存消息记录。
推送消息到RabbitMQ服务端。
消息记录的查询、补偿推送等等。
事务消息的补偿
虽然之前提到笔者建议下游服务自理自身服务消费异常的场景,但是有些时候迫于无奈还是需要上游把对应的消息重新推送,这个算是特殊的场景。
另外还有一个场景需要考虑:事务提交之后触发事务同步器TransactionSynchronization的afterCommit()方法失败。这是一个低概率的场景,但是在生产中一定会出现,一个比较典型的原因就是:事务提交完成后尚未来得及触发TransactionSynchronization#afterCommit()方法进行推送服务实例就被重启。
如下图所示:
为了统一处理补偿推送的问题,使用了有限状态判断消息是否已经推送成功:
在事务方法内,保存事务消息的时候,标记消息记录推送状态为处理中。
事务同步器接口TransactionSynchronization的afterCommit()方法的实现中,推送对应的消息到RabbitMQ,然后更变事务消息记录的状态为推送成功。
还有一种极为特殊的情况是RabbitMQ服务端本身出现故障导致消息推送异常,这种情况下需要进行重试(补偿推送),经验证明短时间内的反复重试是没有意义的,故障的服务一般不会瞬时恢复,所以可以考虑使用指数退避算法进行重试,同时需要限制最大重试次数。
指数值、间隔值和最大重试次数上限需要根据实际情况设定,否则容易出现消息延时过大或者重试过于频繁等问题。
表设计
事务消息模块主要涉及两张表,以MySQL为例,建表DDL如下:
CREATE TABLE `t_transactional_message`
(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
creator VARCHAR(20) NOT NULL DEFAULT 'admin',
editor VARCHAR(20) NOT NULL DEFAULT 'admin',
deleted TINYINT NOT NULL DEFAULT 0,
current_retry_times TINYINT NOT NULL DEFAULT 0 COMMENT '当前重试次数',
max_retry_times TINYINT NOT NULL DEFAULT 5 COMMENT '最大重试次数',
queue_name VARCHAR(255) NOT NULL COMMENT '队列名',
exchange_name VARCHAR(255) NOT NULL COMMENT '交换器名',
exchange_type VARCHAR(8) NOT NULL COMMENT '交换类型',
routing_key VARCHAR(255) COMMENT '路由键',
business_module VARCHAR(32) NOT NULL COMMENT '业务模块',
business_key VARCHAR(255) NOT NULL COMMENT '业务键',
next_schedule_time DATETIME NOT NULL COMMENT '下一次调度时间',
message_status TINYINT NOT NULL DEFAULT 0 COMMENT '消息状态',
init_backoff BIGINT UNSIGNED NOT NULL DEFAULT 10 COMMENT '退避初始化值,单位为秒',
backoff_factor TINYINT NOT NULL DEFAULT 2 COMMENT '退避因子(也就是指数)',
INDEX idx_queue_name (queue_name),
INDEX idx_create_time (create_time),
INDEX idx_next_schedule_time (next_schedule_time),
INDEX idx_business_key (business_key)
) COMMENT '事务消息表';
CREATE TABLE `t_transactional_message_content`
(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
message_id BIGINT UNSIGNED NOT NULL COMMENT '事务消息记录ID',
content TEXT COMMENT '消息内容'
) COMMENT '事务消息内容表';
因为此模块有可能扩展出一个后台管理模块,所以要把消息的管理和状态相关字段和大体积的消息内容分别存放在两个表,从而避免大批量查询消息记录的时候MySQL服务IO使用率过高的问题(这是和上一个公司的DBA团队商讨后得到的一个比较合理的方案)。预留了两个业务字段business_module和business_key用于标识业务模块和业务键(一般是唯一识别号,例如订单号)。
一般情况下,如果服务通过配置自行提前声明队列和交换器的绑定关系,那么发送RabbitMQ消息的时候其实只依赖于exchangeName和routingKey两个字段(header类型的交换器是特殊的,也比较少用,这里暂时不用考虑),考虑到服务可能会遗漏声明操作,发送消息的时候会基于队列进行首次绑定声明并且缓存相关的信息(RabbitMQ中的队列-交换器绑定声明只要每次声明绑定关系的参数一致,则不会抛出异常)。
小结
事务消息模块的设计仅仅是使异步消息推送这个功能实现趋向于完备,其实一个合理的异步消息交互系统,一定会提供同步查询接口,这一点是基于异步消息没有回调或者没有响应的特性导致的。
一般而言,一个系统的吞吐量和系统的异步化处理占比成正相关(这一点可以参考Amdahl's Law),所以在系统架构设计实际中应该尽可能使用异步交互,提高系统吞吐量同时减少同步阻塞带来的无谓等待。事务消息模块可以扩展出一个后台管理,甚至可以配合Micrometer、Prometheus和Grafana体系做实时数据监控。
本文demo项目仓库:rabbit-transactional-message
demo必须本地安装MySQL、Redis和RabbitMQ才能正常启动,本地必须新建一个数据库命名local。
欢迎在留言区留下你的观点,一起讨论提高。如果今天的文章让你有新的启发,学习能力的提升上有新的认识,欢迎转发分享给更多人。
欢迎各位读者加入订阅号程序员小乐技术群,在后台回复“加群”或者“学习”即可。
猜你还想看
无监督方法实现C++、Java、Python 代码转换,程序员:出了bug怎么办,两种语言都要看吗?
关注订阅号「程序员小乐」,收看更多精彩内容
嘿,你在看吗?