原創/朱季謙
曾經在SpringCloudAlibaba的Seata分佈式事務搭建過程中,跨節點通過openfeign調用不同服務時,發現全局事務XID在當前節點也就是TM處,是正常能通過RootContext.getXID()獲取到分佈式全局事務XID的,但在下游節點就出現獲取爲NULL的情況,導致全局事務失效,出現異常時無法正常回滾。
當時看了一遍源碼,才知道問題所在,故而把這個過程瞭解到的分佈式事務XID是如何跨節點傳輸的原理記錄下來。
本文默認是使用Seata的AT模式。
在那一次的搭建過程中,我設置了三個節點,分別是訂單節點order,商品庫存節點product,賬戶餘額節點account,模擬購買下單邏輯,在分佈式環境下,生成一份訂單時,通過openfeign遠程扣減庫存,最後同樣通過openfeign去扣減賬戶(當然,實際場景遠不止這些,這裏只是簡單模擬這個過程)。
正常情況下,其中有一步出錯,整個全局分佈式事務就會進行回滾。
這三個節點在Seata AT模式下,流程圖是這樣的,order充當TM/RM角色,product和充當RM角色,按照在Linux服務器上的Seats Service就充當TC角色。
首先是最初調用訂單節點order業務邏輯——
@Override
@Transactional
@GlobalTransactional(name = "zjq-create-order",rollbackFor = Exception.class)
public RestResponse createOrder(Orders order) {
log.info("當前的XID:"+ RootContext.getXID());
log.info("------>開始新建訂單");
//1、新建訂單
orderMapper.insert(order);
//2、扣減庫存
productService.decrease(order.getProductId(),order.getCount());
//3、扣減賬戶
accountService.decrease(order.getUserId(),order.getMoney());
......
}
在Seata,order充當了TM角色,負責生成一個全局事務註冊到TC,TC會返回一個全局事務ID給TM。
在該全局事務流程裏,每一個分支模塊理應都能獲取到這一個共同的全局事務ID,在該全局事務ID統籌下,完成分支事務的提交或者回滾。
通過RootContext.getXID()獲取到一個全局事務ID爲:192.168.1.152:8091:458311058765479936
創建訂單成功後,就會執行扣減庫存操作productService.decrease(order.getProductId(),order.getCount())。
在該代碼案例裏,productService.decrease()內部是通過openfeign遠程去調用的——
@FeignClient(contextId = "remoteProductService",value = "zjq-product",fallbackFactory = RemoteProductServiceFallbackFactory.class)
public interface RemoteProductService {
@PostMapping(value = "/product/decrease")
RestResponse decrease(@RequestParam("productId")Long productId, @RequestParam("count") Integer count);
}
最終decrease的服務層僞代碼大概如下——
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int decrease(Long productId, Integer count) {
log.info("當前的XID:"+ RootContext.getXID());
log.info("---------->開始查詢商品是否存在");
log.info("---------->開始扣減庫存");
......
}
然而,到這一步,發現了一個問題,這裏獲取的全局事務ID爲null——
這說明了一個問題,TM開啓了一個全局事務後,已經從TC那裏獲取到了一個全局事務ID,但遠程傳送給product這個RM資源管理器後,沒有傳送成功,同理,另一個分支事務account模塊的,同樣獲取到的全局事務ID爲null。
基於這樣一個現象,我就開始嘗試研究了一下全局事務是如何在Openfeign跨節點環境進行傳輸和獲取的,主要分爲TM節點的全局事務ID發送和遠程RM節點的接收。
一、TM節點的全局事務ID發送
通過debug代碼去閱讀,在調用 productService.decrease(order.getProductId(),order.getCount())時,內部做了反射調用,執行了一系列方案調用,調用核心過程如下——
本文只需要關注在整個HTTP調用過程,全局事務ID是如何放進來的,這個調用鏈涉及的類及方法,在後續學習中再進一步研究。
最終在SeataFeignClient的execute方法裏,可以看到以下源碼——
public Response execute(Request request, Request.Options options) throws IOException {
Request modifiedRequest = this.getModifyRequest(request);
return this.delegate.execute(modifiedRequest, options);
}
其中,在Request modifiedRequest = this.getModifyRequest(request)這行代碼裏,對請求頭做了一些補充操作。
private Request getModifyRequest(Request request) {
String xid = RootContext.getXID();
if (StringUtils.isEmpty(xid)) {
return request;
} else {
Map<String, Collection<String>> headers = new HashMap(16);
headers.putAll(request.headers());
List<String> seataXid = new ArrayList();
seataXid.add(xid);
headers.put("TX_XID", seataXid);
return Request.create(request.method(), request.url(), headers, request.body(), request.charset());
}
}
debug到這裏,可以看到,這裏將一個全局事務ID存儲到了headers裏——
這個headers其實是HTTP組裝的請求頭,可以看到,這裏是將全局事務ID放到了HTTP請求頭裏,傳送給了遠程機器。
Request(HttpMethod method, String url, Map<String, Collection<String>> headers, Body body, RequestTemplate requestTemplate) {
this.httpMethod = (HttpMethod)Util.checkNotNull(method, "httpMethod of %s", new Object[]{method.name()});
this.url = (String)Util.checkNotNull(url, "url", new Object[0]);
this.headers = (Map)Util.checkNotNull(headers, "headers of %s %s", new Object[]{method, url});
this.body = body;
this.requestTemplate = requestTemplate;
}
通過debug,可以發現,在HTTP組裝過程中,已經將全局事務ID放到了請求頭裏,說明在HTTP發生成功後,是會攜帶全局事務到遠程product模塊的,但是爲何product模塊打印RootContext.getXID()得到的是null呢?
二、跨節點分支事務獲取全局事務ID
HTTP請求傳送到遠程product模塊後,在調用具體的Controller前,會流轉到MVC進行攔截轉發,在這過程當中,涉及到seata分佈式事務時,理應會有這樣一個叫TransactionPropagationInterceptor的攔截器,用來處理分佈式事務的傳播,有兩個方法,分別是preHandle()和afterCompletion(),暫時只需要關注preHandle方法即可:
- preHandle()
在處理遠程請求之前被調用,在該方法中,通過RootContext.getXID()獲取到當前線程上下文中的全局事務ID和通過request.getHeader("TX_XID")獲取HTTP請求頭中的事務ID。這裏的請求頭裏的事務ID,正是前面發送HTTP時放到請求頭裏的。
若RootContext.getXID()獲取到當前線程上下文中的全局事務ID爲空並且HTTP請求頭的事務ID不爲空,就會將該HTTP請求頭裏的事務ID綁定到該線程上下文當中,用於確保全局事務的傳播和關聯。
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String xid = RootContext.getXID();
String rpcXid = request.getHeader("TX_XID");
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("xid in RootContext[{}] xid in HttpContext[{}]", xid, rpcXid);
}
if (StringUtils.isBlank(xid) && StringUtils.isNotBlank(rpcXid)) {
RootContext.bind(rpcXid);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("bind[{}] to RootContext", rpcXid);
}
}
return true;
}
進入到bind方法當中,可以看到,這裏是HTTP請求頭裏的事務ID緩存到了 CONTEXT_HOLDER.put("TX_XID", xid),它本質其實是一個ThreadLocal,可以存儲線程隔離的變量。
public static void bind(@Nonnull String xid) {
if (StringUtils.isBlank(xid)) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("xid is blank, switch to unbind operation!");
}
unbind();
} else {
MDC.put("X-TX-XID", xid);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("bind {}", xid);
}
CONTEXT_HOLDER.put("TX_XID", xid);
}
}
緩存成功後,下一次通過 RootContext.getXID()就能獲取到該線程緩存的全局事務ID了, RootContext.getXID()本質就是——
public static String getXID() {
return (String)CONTEXT_HOLDER.get("TX_XID");
}
在本次搭建seata環境中,發現該TransactionPropagationInterceptor過濾器當中的preHandle方法一直沒有執行,這就造成全局事務當中,遠程跨環境的分支事務節點一直無法獲取到全局事務ID。
於是,我嘗試手動將該TransactionPropagationInterceptor攔截器加入到Spring MVC流程中——
@Configuration
public class WebMvcInterceptorsConfig extends WebMvcConfigurationSupport {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TransactionPropagationInterceptor());
}
@Bean
public ServerCodecConfigurer serverCodecConfigurer() {
return ServerCodecConfigurer.create();
}
}
重新運行後,這次攔截器TransactionPropagationInterceptor終於生效裏,可以debug到了preHandle方法裏,將HTTP請求頭的全局事務ID取出,然後通過RootContext.bind(rpcXid)緩存到線程上下文當中——
這時,product節點終於能拿到從TM遠程傳送過來的全局事務ID了——
最後總結一下,全局事務ID在SpringCloudAlibaba Seata在Openfeign跨節點環境裏的傳送方式,是將該全局事務ID放入到HTTP請求頭當中,遠程傳送給分支事務節點,各分支事務節點會在TransactionPropagationInterceptor攔截器當中,取出HTTP請求頭大全局事務ID,通過RootContext.bind(rpcXid)將全局事務ID緩存到線程上下文裏,這樣,分支事務就可以在其執行過程當中,獲取到全局事務ID啦。