前言
隨着互聯網公司的業務的不斷拓展,數據量以及用戶的不斷增多,越來越多的公司的系統架構從最開始的單系統架構轉爲了多系統間的分佈式架構,這種方式能夠大大減少系統間的耦合性,同時也能緩解系統壓力;但是同時也帶來了系統間數據不一致的問題,在接口的調用和數據的傳輸寫入等操作中,往往很難保證事務的可靠性。
通常分佈式事務有以下幾種解決方案:
1.基於數據庫XA/JTA協議的方式(需要數據庫廠商支持;JAVA組件有atomikos等);
2.異步校對數據的方式(支付寶、微信支付主動查詢支付狀態、對賬的形式);
3.基於可靠消息(MQ)的解決方案;
4.TCC編程式解決方案;
其中基於MQ的事務解決方案適用於異步場景,且通用性較強,拓展性較高,本文將使用中間件RabbitMQ來解決分佈式事務的問題。
事務問題的產生
1. 業務場景
以外賣配送場景爲例,這裏將系統簡化爲兩個系統,一個爲訂單系統,該系統記錄了用戶下單的信息;另一個系統爲運單系統,記錄了訂單的一系列配送信息。
2. 問題產生
此時將會產生一個問題,兩個系統都爲獨立的系統,數據也分別存儲於不同的數據庫,在用戶下單後,需要將訂單系統的這份下單數據存儲一份數據到運單系統,然而因爲是跨系統的接口調用,使用事務註解@Transactional能否解決數據一致性的問題呢?
3. 示例代碼
3.1.訂單數據庫中的訂單表:
CREATE TABLE `table_order` (
`order_id` varchar(100) NOT NULL COMMENT '訂單號',
`user_id` varchar(255) DEFAULT NULL COMMENT '用戶編號',
`order_content` varchar(255) DEFAULT NULL COMMENT '訂單內容(買了哪些東西,送貨地址)',
`create_time` datetime NOT NULL COMMENT '創建時間',
PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.2.運單數據庫中的運單表:
CREATE TABLE `table_dispatch` (
`order_id` varchar(100) NOT NULL COMMENT '訂單編號',
`dispatch_seq` varchar(255) DEFAULT NULL COMMENT '調度流水號',
`dispatch_status` varchar(255) DEFAULT NULL COMMENT '調度狀態',
`dispatch_content` varchar(255) DEFAULT NULL COMMENT '調度內容(送餐員,路線)',
PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.3 運單系統代碼:
3.3.1 運單系統 http API 向外提供接口:
/**
* 運單系統http API
*/
@RestController
@RequestMapping("/dispatch-api")
public class DispatchController {
@Autowired
private DispatchService dispatchService;
// 下訂單後,添加調度信息
@GetMapping("/dispatch")
public String lock(String orderId) throws Exception {
Thread.sleep(3000L); // 此處模擬業務耗時,接口調用者會認爲超時
dispatchService.dispatch(orderId); // 將外賣訂單分配給送餐小哥
return "ok";
}
}
3.3.2 運單系統調度相關,將運單數據存入數據庫:
/**
* 運單系統http API
*/
@RestController
@RequestMapping("/dispatch-api")
public class DispatchController {
@Autowired
private DispatchService dispatchService;
// 下訂單後,添加調度信息
@GetMapping("/dispatch")
public String lock(String orderId) throws Exception {
Thread.sleep(3000L); // 此處模擬業務耗時,接口調用者會認爲超時
dispatchService.dispatch(orderId); // 將外賣訂單分配給送餐小哥
return "ok";
}
}
3.3.3 運單系統啓動類
@SpringBootApplication
public class DispatchApplication {
public static void main(String[] args) throws Exception {
new SpringApplicationBuilder(DispatchApplication.class).web(WebApplicationType.SERVLET)
.run(args);
}
}
3.4 訂單系統代碼:
3.4.1 訂單系統orderservice,該類做了兩件事,一是將訂單數據插入訂單數據庫,另一件事是遠程調用運單系統的api接口,通過http接口的形式將訂單信息傳給運單系統,並且讓運單系統存儲運單信息。
@Service
public class OrderService {
@Autowired
OrderDatabaseService orderDatabaseService;
/** 創建訂單 */
@Transactional(rollbackFor = Exception.class) // 訂單創建整個方法添加事務
public void createOrder(JSONObject orderInfo) throws Exception {
// 1. 訂單信息 - 插入訂單系統,訂單數據庫(事務-1)
orderDatabaseService.saveOrder(orderInfo);
// 2. 通過http接口發送訂單信息到 運單系統
String result = callDispatchHttpApi(orderInfo);
if (!"ok".equals(result)) {
throw new Exception("訂單創建失敗,原因[運單接口調用失敗]");
}
}
/**
* 通過http接口發送 運單系統,將訂單號傳過去
*
* @return 接口調用結果
*/
public String callDispatchHttpApi(JSONObject orderInfo) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
// 鏈接超時時間 > 3秒
requestFactory.setConnectTimeout(3000);
// 處理超時時間 > 2 秒
requestFactory.setReadTimeout(2000);
RestTemplate restTemplate = new RestTemplate(requestFactory);
String httpUrl = "http://127.0.0.1:8080/dispatch-api/dispatch?orderId=" + orderInfo.getString("orderId");
String result = restTemplate.getForObject(httpUrl, String.class);
return result;
}
}
3.4.2 訂單系統測試類:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = OrderApplication.class)
public class OrderApplicationTests {
@Before
public void start() {
System.out.println("開始測試##############");
}
@After
public void finish() {
System.out.println("結束##############");
}
@Autowired
OrderService orderService;
@Test
public void orderCreate() throws Exception {
// 訂單號生成
String orderId = UUID.randomUUID().toString();
JSONObject orderInfo = new JSONObject();
orderInfo.put("orderId", orderId);
orderInfo.put("userId", "hy");
orderInfo.put("orderContent", "重慶火鍋");
orderService.createOrder(orderInfo);
System.out.println("訂單創建成功");
}
}
4. 測試結果
在測試結果中可以看到,訂單系統因爲調用接口超時而發生了報錯:org.springframework.web.client.ResourceAccessException: I/O error on GET request for “http://127.0.0.1:8080/dispatch-api/dispatch”: Read timed out; nested exception is java.net.SocketTimeoutException: Read timed out。
那麼,因爲我們在 createOrder 這個方法上添加了Transactional註解,該方法應該要回滾,去數據庫中查看數據:
訂單系統數據庫的表 table_order:
運單系統數據庫的表 table_dispatch:
可以看到,訂單表裏面沒有數據,可是運單表卻有數據,原因是雖然調用方雖然超時,但是被調用方卻依然還在處理代碼,這種情況下導致一個訂單雖然被取消,但是配送員卻接到了一個根本不存在的訂單,顯然這樣是有問題的。
分佈式事務解決方法
1. 消息中間件
消息中間件則是將軟件與軟件之間的交互方式進行存儲和管理的一種技術,可以理解爲是一個消息隊列,數據發送方將(生產者)數據發送到消息中間件,然後由數據接收端(消費者)來讀取消息中間件的數據,在消費者未讀取到生產者發送的數據前,數據將會存儲在消息中間件中;常見的消息中間件有Active MQ,Rabbit MQ,Rocket MQ,Kafka,Redis等,在這裏我們使用Rabbit MQ來進行消息的存儲和消費。
2. 設計思路
在訂單創建,將訂單數據存入 table_order 後,不再調用運單系統的接口去將數據存入 table_dispatch中,而是將數據發送至 Rabbit MQ 中,運單系統再從MQ中讀取數據存入運單系統的表中;但是此時需要解決兩個問題:1.生產可靠:保證生產者一定能將數據發送到Rabbit MQ中;2.消費可靠:保證消費者一定能將MQ中的數據取出來正確消費掉。
3. 實現方案
3.1 在訂單數據庫中創建本地記錄表 tb_distributed_message
CREATE TABLE `tb_distributed_message` (
`unique_id` varchar(100) NOT NULL COMMENT '唯一ID',
`msg_content` varchar(255) DEFAULT NULL COMMENT '消息內容',
`msg_status` int(11) DEFAULT NULL COMMENT '是否發送到MQ:0:已發送;1:未發送',
`create_time` datetime DEFAULT NULL COMMENT '消息創建時間',
PRIMARY KEY (`unique_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.1.1 爲了確保數據一定發送到MQ中,在同一事務中,增加一個記錄表的操作,記錄每一條發往MQ的數據以及它的發送狀態;同時,還需要開啓Rabbit MQ的發佈確認機制(在配置文件中添加 publisher-confirms: true),開啓確認發佈機制後,MQ準確受理消息會返回回執,若訂單系統收到MQ發送的回執,那麼將之前存入本地記錄表 tb_distributed_message 的 msg_status 從1改爲0。
3.1.2 爲了確保消費者一定能收到MQ中的消息,需要手動開啓ACK模式(配置文件中添加acknowledge-mode: MANUAL),由消費者控制消息的重發/清除/丟棄,並且還需要保證冪等性,防止數據的重複處理,一次用戶操作,只對應一次數據處理,可以根據ID或者業務數據來進行去重(可利用redis的特性進行去重操作),這裏將orderid設置爲主鍵,利用數據庫主鍵的特性進行去重操作;另外因爲某些特殊原因(因爲系統和數據庫間的網絡原因/消息內容格式錯誤等),導致消費端數據處理一直失敗,那麼可以將數據直接丟棄或者轉移到死信隊列(DLQ)。
3.2 訂單系統代碼修改
3.2.1 OrderService中不再調用遠程接口,將數據寫入MQ
@Service
public class OrderService {
@Autowired
OrderDatabaseService orderDatabaseService;
@Autowired
MQService mQService;
/** 創建訂單 */
@Transactional(rollbackFor = Exception.class) // 訂單創建整個方法添加事務
public void createOrder(JSONObject orderInfo) throws Exception {
// 1. 訂單信息 - 插入訂單系統,訂單數據庫(事務-1)
orderDatabaseService.saveOrder(orderInfo);
// 向mq中發送數據
mQService.sendMsg(orderInfo);
}
}
3.2.2 發送消息,修改本地表類
/**
* 這是一個發送MQ消息,修改消息表的地方
*
*/
@Service
@Transactional(rollbackFor = Exception.class)
public class MQService {
private final Logger logger = LoggerFactory.getLogger(MQService.class);
@Autowired
JdbcTemplate jdbcTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
@PostConstruct
public void setup() {
// 消息發送完畢後,則回調此方法 ack代表發送是否成功
rabbitTemplate.setConfirmCallback(new ConfirmCallback() {
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
// ack爲true,代表MQ已經準確收到消息
if (!ack) {
return;
}
try {
// 2. 修改本地消息表的狀態爲“已發送”。刪除、修改狀態
String sql = "update tb_distributed_message set msg_status=1 where unique_id=?";
int count = jdbcTemplate.update(sql, correlationData.getId());
if (count != 1) {
logger.warn("警告:本地消息表的狀態修改不成功");
}
} catch (Exception e) {
logger.warn("警告:修改本地消息表的狀態時出現異常", e);
}
}
});
}
/**
* 發送MQ消息,修改本地消息表的狀態
*
* @throws Exception
*/
public void sendMsg(JSONObject orderInfo) throws Exception {
// 1. 發送消息到MQ
// CorrelationData 當收到消息回執時,會附帶上這個參數
rabbitTemplate.convertAndSend("createOrderExchange", "", orderInfo.toJSONString(),
new CorrelationData(orderInfo.getString("orderId")));
}
}
在此過程中可能出現生產者發送消息失敗或者回執消息發送失敗,那麼我們可以另外再開啓一個定時任務去週期性地檢查本地記錄表,如果發現在一定時間內消息的狀態沒有改變,那麼可以進行消息重發。
3.3 運單系統代碼修改
3.3.1 添加消費者
@Component
public class OrderDispatchConsumer {
private final Logger logger = LoggerFactory.getLogger(OrderDispatchConsumer.class);
@Autowired
DispatchService dispatchService;
@RabbitListener(queues = "orderDispatchQueue")
public void messageConsumer(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag)
throws Exception {
try {
// mq裏面的數據轉爲json對象
JSONObject orderInfo = JSONObject.parseObject(message);
logger.warn("收到MQ裏面的消息:" + orderInfo.toJSONString());
Thread.sleep(5000L);
// 執行業務操作,同一個數據不能處理兩次,根據業務情況去重,保證冪等性。 (這裏可以用redis記錄處理情況)
String orderId = orderInfo.getString("orderId");
// 這裏就是一個分配快遞員...
dispatchService.dispatch(orderId);
// ack - 告訴MQ,已經收到消息
channel.basicAck(tag, false);
} catch (Exception e) {
// 異常情況 :根據需要去: 重發/ 丟棄
// 重發一定次數後, 丟棄, 日誌告警,防止mq一直重發進入死循環(redis記錄每條消息的處理次數)
channel.basicNack(tag, false, false);
// 系統 關鍵數據,永遠是有人工干預
}
// 如果不給回覆,就等這個consumer斷開鏈接後,mq-server會繼續推送
}
}
4. 測試結果
將訂單系統啓動,可以看到 table_order表:
tb_distributed_message 表:
mq中收到一條數據:
接着將運單系統啓動起來:
發現運單系統通過消費mq的數據, table_dispatch 已經插入了數據:
至此,該方法已經可以解決先前所出現的問題,有效保證了數據的一致性和可靠性。
總結
現實生產過程中所碰到的分佈式的事務問題是多種多樣的,本文只是提供了一個解決分佈式事務數據一致性的思想,主要使用的是中間件的功能,但是,具體情況還是需要根據相應的業務功能去使用不同的組件和方法去解決。