SpringCloudAlibaba Seata在Openfeign跨節點環境出現全局事務Xid失效原因底層探究

image

原創/朱季謙

曾經在SpringCloudAlibaba的Seata分佈式事務搭建過程中,跨節點通過openfeign調用不同服務時,發現全局事務XID在當前節點也就是TM處,是正常能通過RootContext.getXID()獲取到分佈式全局事務XID的,但在下游節點就出現獲取爲NULL的情況,導致全局事務失效,出現異常時無法正常回滾。

當時看了一遍源碼,才知道問題所在,故而把這個過程瞭解到的分佈式事務XID是如何跨節點傳輸的原理記錄下來。

本文默認是使用Seata的AT模式。

在那一次的搭建過程中,我設置了三個節點,分別是訂單節點order,商品庫存節點product,賬戶餘額節點account,模擬購買下單邏輯,在分佈式環境下,生成一份訂單時,通過openfeign遠程扣減庫存,最後同樣通過openfeign去扣減賬戶(當然,實際場景遠不止這些,這裏只是簡單模擬這個過程)。

正常情況下,其中有一步出錯,整個全局分佈式事務就會進行回滾。
image

這三個節點在Seata AT模式下,流程圖是這樣的,order充當TM/RM角色,product和充當RM角色,按照在Linux服務器上的Seats Service就充當TC角色。
image

首先是最初調用訂單節點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
image

創建訂單成功後,就會執行扣減庫存操作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——
image

這說明了一個問題,TM開啓了一個全局事務後,已經從TC那裏獲取到了一個全局事務ID,但遠程傳送給product這個RM資源管理器後,沒有傳送成功,同理,另一個分支事務account模塊的,同樣獲取到的全局事務ID爲null。

基於這樣一個現象,我就開始嘗試研究了一下全局事務是如何在Openfeign跨節點環境進行傳輸和獲取的,主要分爲TM節點的全局事務ID發送和遠程RM節點的接收。

一、TM節點的全局事務ID發送

通過debug代碼去閱讀,在調用 productService.decrease(order.getProductId(),order.getCount())時,內部做了反射調用,執行了一系列方案調用,調用核心過程如下——
image

本文只需要關注在整個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裏——
image

這個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)緩存到線程上下文當中——
image

這時,product節點終於能拿到從TM遠程傳送過來的全局事務ID了——
image

最後總結一下,全局事務ID在SpringCloudAlibaba Seata在Openfeign跨節點環境裏的傳送方式,是將該全局事務ID放入到HTTP請求頭當中,遠程傳送給分支事務節點,各分支事務節點會在TransactionPropagationInterceptor攔截器當中,取出HTTP請求頭大全局事務ID,通過RootContext.bind(rpcXid)將全局事務ID緩存到線程上下文裏,這樣,分支事務就可以在其執行過程當中,獲取到全局事務ID啦。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章