はじめに
前回の記事では rocketmq-spring-boot-starter の使用手順を紹介しましたが、本記事では RocketMQ を使用して分散環境におけるトランザクションの問題を解決する方法を紹介します。
rocketmq-spring-boot-starter に慣れていない場合は、最初に前回の記事「 Springboot Integration RocketMQ チュートリアル」を読むことをお勧めします。
1: シーンシミュレーション
シナリオ: 現在、このようなビジネスがあるとします。ユーザーはネットワーク料金をチャージすることでポイントを獲得し、1 元 = 1 ポイントとして、ユーザー サービスで 100 元をチャージし、ポイント サービスでユーザーに 100 ポイントを追加します。
分析: このようなサービス間およびライブラリ間操作の場合、これら 2 つの操作が一緒に成功するか一緒に失敗することを確認する必要があります。RocketMQ ソリューションは次のとおりです: RocketMQ トランザクション メッセージ + ローカル トランザクション + 最終的な一貫性を達成するための消費量の監視
実装する前にRocketMQのトランザクションを紹介しましょう
2: RocketMQ トランザクションの概要
1. 基本概念
(1) ハーフメッセージ: Prepare Message とも呼ばれ、「ハーフメッセージ」または「準備メッセージ」と訳され、一時的に配信できないメッセージ、つまりメッセージが MQ サーバーに正常に送信されたメッセージを指します。消費に関しては、サーバーがプロデューサから 2 回目の確認を受信した場合にのみ、コンシューマで消費できます。
(2) メッセージ ステータス チェック: メッセージのステータスを確認します。トランザクション メッセージの 2 回目の確認応答は、ネットワークの切断またはプロデューサ アプリケーションの再起動により失われる可能性があります。MQ サーバーは、メッセージが長時間ハーフメッセージ状態のままであることを検出すると、メッセージ プロデューサにリクエストを送信します。メッセージの最終ステータス (「コミット」または「ロールバック」) を確認します。
2. 実行フローチャート(ProcessOnツール)
- プロデューサはハーフメッセージを MQ サーバーに送信しますが、これは一時的に配信できず、消費されません。
- ハーフメッセージが正常に送信された後、プロデューサはローカル トランザクションを実行します。
- プロデューサは、ローカル トランザクションの実行結果に基づく二次確認のために、MQ サーバーにコミット メッセージまたはロールバック メッセージを送信します。
- MQ サーバーがコミットを受信すると、ハーフ メッセージを配信可能としてマークし、コンシューマーはこの時点で消費できます。ロールバックを受信すると、ハーフ メッセージは直接破棄され、消費されません。
- MQ サーバーが 2 番目の確認メッセージを受信しない場合、MQ サーバーはローカル トランザクションのステータスを確認するために定期的に (デフォルトは 1 分) チェックバック メッセージをプロデューサーに送信し、その後プロデューサーはコミットを送信するか、MQ サーバーに送信します。ローカル トランザクション チェックバック ロールバック メッセージの結果に従って再度
3: ビジネスコードの実装
1. テーブルの作成
(1) ユーザーテーブル
CREATE TABLE `t_user` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户表',
`name` varchar(16) NOT NULL COMMENT '姓名',
`id_card` varchar(32) NOT NULL COMMENT '身份证号',
`balance` int(11) NOT NULL DEFAULT '0' COMMENT '余额',
`state` tinyint(1) DEFAULT NULL COMMENT '状态(1在线,0离线)',
`vip_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'VIP用户标识(1是,0否)',
`create_time` datetime NOT NULL COMMENT '创建时间',
`last_login_time` datetime DEFAULT NULL COMMENT '最后一次登录时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4
(2) ポイントテーブル
CREATE TABLE `t_credit` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '积分表',
`user_id` int(11) NOT NULL COMMENT '用户id',
`username` varchar(16) NOT NULL COMMENT '用户姓名',
`integration` int(11) NOT NULL DEFAULT '0' COMMENT '积分',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4
(3) トランザクションログテーブル
CREATE TABLE `t_mq_transaction_log` (
`transaction_id` varchar(64) NOT NULL COMMENT '事务id',
`log` varchar(64) NOT NULL COMMENT '日志',
PRIMARY KEY (`transaction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
- 私はシミュレーションで、ここにライブラリに入れています。なぜトランザクション ログ テーブルが構築されるかについては、後でわかります。
- テーブルを作成したら、便宜上、まずユーザー テーブルとポイント テーブルにデータを手動で書き込みます。
- エンティティ クラス、マッパー インターフェイスなどのプロジェクト構造はここでは省略されます。
2. 新しい MQ トランザクション プロデューサーを作成します: MQTXProducerService
@Slf4j
@Component
public class MQTXProducerService {
private static final String Topic = "RLT_TEST_TOPIC";
private static final String Tag = "charge";
private static final String Tx_Charge_Group = "Tx_Charge_Group";
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 先向MQ Server发送半消息
* @param userCharge 用户充值信息
*/
public TransactionSendResult sendHalfMsg(UserCharge userCharge) {
// 生成生产事务id
String transactionId = UUID.randomUUID().toString().replace("-", "");
log.info("【发送半消息】transactionId={}", transactionId);
// 发送事务消息(参1:生产者所在事务组,参2:topic+tag,参3:消息体(可以传参),参4:发送参数)
TransactionSendResult sendResult = rocketMQTemplate.sendMessageInTransaction(
Tx_Charge_Group, Topic + ":" + Tag,
MessageBuilder.withPayload(userCharge).setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId).build(),
userCharge);
log.info("【发送半消息】sendResult={}", JSON.toJSONString(sendResult));
return sendResult;
}
}
- ここでは、UUID を使用してトランザクション ID を生成します。これは、上記のトランザクション ログ テーブルの ID です (実際の状況では、スノーフレーク アルゴリズムを使用するか、ビジネスに応じて ID を定義する場合があります)。
- 追加されたメソッド パラメータ userCharge は、ユーザー ID とリチャージ金額を表す userId、chargeAmount の 2 つのフィールドだけの dto として理解できます。
- ここで注意してください: ハーフ メッセージの送信方法には、リファレンス 3 とリファレンス 4 の 2 つのパラメータがあります。前の統合チュートリアルを読んだ人は、このパラメータ 3 が消費者用であり、このパラメータ 4 がローカル事務用であることを知っているはずです。私はシミュレーションと同じです、実際のビジネスは異なる場合があります
3. 新しいローカル トランザクション リスナーを作成します: MQTXLocalService
@Slf4j
@RocketMQTransactionListener(txProducerGroup = "Tx_Charge_Group") // 这里的txProducerGroup的值要与发送半消息时保持一致
public class MQTXLocalService implements RocketMQLocalTransactionListener {
@Autowired
private UserService userService;
@Autowired
private MQTransactionLogMapper mqTransactionLogMapper;
/**
* 用于执行本地事务的方法
*/
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object obj) {
// 获取消息体里参数
MessageHeaders messageHeaders = message.getHeaders();
String transactionId = (String) messageHeaders.get(RocketMQHeaders.TRANSACTION_ID);
log.info("【执行本地事务】消息体参数:transactionId={}", transactionId);
// 执行带有事务注解的本地方法:增加用户余额+保存mq日志
try {
UserCharge userCharge = (UserCharge) obj;
userService.addBalance(userCharge, transactionId);
return RocketMQLocalTransactionState.COMMIT; // 正常:向MQ Server发送commit消息
} catch (Exception e) {
log.error("【执行本地事务】发生异常,消息将被回滚", e);
return RocketMQLocalTransactionState.ROLLBACK; // 异常:向MQ Server发送rollback消息
}
}
/**
* 用于回查本地事务执行结果的方法
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
MessageHeaders headers = message.getHeaders();
String transactionId = headers.get(RocketMQHeaders.TRANSACTION_ID, String.class);
log.info("【回查本地事务】transactionId={}", transactionId);
// 根据事务id查询事务日志表
MQTransactionLog mqTransactionLog = mqTransactionLogMapper.selectByPrimaryKey(transactionId);
if (null == mqTransactionLog) {
// 没查到表明本地事务执行失败,通知回滚
return RocketMQLocalTransactionState.ROLLBACK;
}
return RocketMQLocalTransactionState.COMMIT; // 查到表明本地事务执行成功,提交
}
}
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private MQTransactionLogMapper mqTransactionLogMapper;
/**
* 用户增加余额+事务日志
*/
@Transactional(rollbackFor = Exception.class)
public void addBalance(UserCharge userCharge, String transactionId) {
// 1. 增加余额
userMapper.addBalance(userCharge.getUserId(), userCharge.getChargeAmount());
// 2. 写入mq事务日志
saveMQTransactionLog(transactionId, userCharge);
}
@Transactional(rollbackFor = Exception.class)
public void saveMQTransactionLog(String transactionId, UserCharge userCharge) {
MQTransactionLog transactionLog = new MQTransactionLog();
transactionLog.setTransactionId(transactionId);
transactionLog.setLog(JSON.toJSONString(userCharge));
mqTransactionLogMapper.insertSelective(transactionLog);
}
}
- ここのコードが重要なポイントです。ローカル トランザクションは、ユーザーに残高を追加し、mq トランザクション ログを挿入します。これら 2 つの操作が成功した場合にのみ COMMIT に戻り、異常に失敗した場合は戻ります。ロールバックする
- バックチェック メソッドは実行されない可能性がありますが、実行する必要があります。バックチェックは、前に生成して渡したトランザクション ID (transactionId) に基づいてトランザクション ログ テーブルをクエリします。これの利点は、テーブルの数が関係ないことです。ビジネスに関与しています。私のログ テーブル これはローカル トランザクションにもバインドされています。このトランザクション テーブルをクエリするだけで済みます。それが見つかった場合は、ローカル トランザクションが正常に実行されたことを意味します。
- ここでポイントになりますが、上記の addBalance メソッドには欠陥があり、addBalance メソッドに @Transactional アノテーションが追加されていても、saveMQTransactionLog メソッドが異常であるとトランザクションが有効になりません。これには Spring のトランザクション メカニズムの原理が関係します (本質的には によって実装されます)。 AOP+ ダイナミック プロキシ ) ですが、私はシミュレーションのためにここにいるので、この詳細は除外します
4. 新しいトランザクション メッセージ コンシューマーを作成します: MQTXConsumerService
@Slf4j
@Component
@RocketMQMessageListener(topic = "RLT_TEST_TOPIC", selectorExpression = "charge", consumerGroup = "Con_Group_Four") // topic、tag保持一致
public class MQTXConsumerService implements RocketMQListener<UserCharge> {
@Autowired
private CreditMapper creditMapper;
@Override
public void onMessage(UserCharge userCharge) {
// 一般真实环境这里消费前,得做幂等性判断,防止重复消费
// 方法一:如果你的业务中有某个字段是唯一的,有标识性,如订单号,那就可以用此字段来判断
// 方法二:新建一张消费记录表t_mq_consumer_log,字段consumer_key是唯一性,能插入则表明该消息还未消费,往下走,否则停止消费
// 我个人建议用方法二,根据你的项目业务来定义key,这里我就不做幂等判断了,因为此案例只是模拟,重在分布式事务
// 给用户增加积分
int i = creditMapper.addNumber(userCharge.getUserId(), userCharge.getChargeAmount());
if (1 == i) {
log.info("【MQ消费】用户增加积分成功,userCharge={}", JSONObject.toJSONString(userCharge));
} else {
log.error("【MQ消费】用户充值增加积分消费失败,userCharge={}", JSONObject.toJSONString(userCharge));
}
}
}
- コンシューマは実際には比較的単純で、通常のコンシューマと同様であり、属性の構成に注意するだけです。
- ここで、以前の送信トランザクションとローカル トランザクション (コミットまたはロールバック) には何も問題がないのではないかと疑問に思うかもしれませんが、ここで消費が失敗したらどうなるでしょうか? 実際、ここで問題が発生する可能性はほとんどありません。第一に、RocketMQ は可用性が高いです。システムが非常に大きい場合は、クラスタ化できます。さらに、ここでの消費が成功したかどうかに関係なく、ソース コードは処理されています内部的には、異常がない限り消費され、再試行メカニズムもあります。最後に、ここで消費ロジックを拡張できます。消費が失敗した場合は、記録を保存し、定期的に通知するか、手動で処理することができますそれ
4: テスト
RocketMQController に追加します。
@PostMapping("/charge")
public Result<TransactionSendResult> charge(UserCharge userCharge) {
TransactionSendResult sendResult = mqtxProducerService.sendHalfMsg(userCharge);
return Result.success(sendResult);
}
postman を使用して http://localhost:8080/rocketmq/charge を呼び出し
、正常であることを確認してからデータベースに移動すると、残高とポイントから 100 が追加され、トランザクション ログ テーブルにも記録されていることがわかります。成功!
概要: 実際、トランザクションの実装プロセスを理解すると、RocketMQ を使用して分散トランザクションを解決するのは非常に簡単であることがわかります。結局のところ、MQ は非常に使いやすく、MQ には多くの用途があり、あらゆるプロジェクトで使用できます。もちろん、現在では他にも人気のある専門的な分散トランザクション ソリューションがあるため、Seata について言及する必要がありますが、プロジェクトに特に Seata が必要ない場合、MQ で解決できるのであれば、導入する Seata コンポーネントを 1 つ減らすことができます。それ?
ここに少し個人的な意見があります。分散トランザクション ソリューションの選択は、実際のビジネスに合わせて考慮する必要があります。その場合、私の提案は次のとおりです。ビジネスが特定の操作を行っているユーザーである場合、たとえ何かが起こったとしても、それは間違いなくダウンします。も後方なので、RocketMQ を 100% 使用しても問題ありません。最も単純な例で言えば、ユーザーはお金を支払ったが、フォローアッププログラムに問題があった。システムに問題があるとユーザーに連絡することは不可能である。返金したらどうだろうか。最初にお金を渡して、後でまた注文して購入することができます。そうすれば、後で誰もあなたのシステムを使用しないと思います。つまり、実際の制作においては、業務に応じて柔軟に活用しなければなりません。