如何實現簡單的分散式鏈路功能?

程式設計師小波與Bug發表於2023-09-26

為什麼需要鏈路跟蹤

為什麼需要鏈路跟蹤?微服務環境下,服務之間相互呼叫,可能存在 A->B->C->D->C 這種複雜的服務互動,那麼需要一種方法可以將一次請求鏈路完整記錄下來,否則排查問題不好下手、請求日誌也無法完整串起來。

如何實現鏈路跟蹤

假設我們從使用者請求介面開始,每次請求需要有唯一的請求 id (我們暫且標記為 traceId)來標識這次請求,然後當介面收到請求後,呼叫後續服務或者 mq 時,能將該 traceId 一直傳遞下去,並且 log 日誌中可以列印出來,這樣就實現了簡單的鏈路功能。

如何為每次請求生成一個唯一的 requestId

微服務環境下,使用者請求一般優先經過閘道器,閘道器再將請求轉發到各個服務。微服務閘道器多種多樣,比如 Nginx、Zuul、Spring Cloud Gateway、Kong、Traefik 等,假設存在這樣一條鏈路,使用者請求 -> nginx -> zuul -> service-a、service-b 等(這裡我們使用 Eureka 作為服務註冊中心,使用 Feign 來實現微服務之間相互呼叫、使用 Zuul 作為服務前置閘道器),呼叫大致如下:

這樣的話,從 nginx 請求開始,我們需要標識本次請求的 traceId,然後可以將 traceId 一直傳遞到 service 服務層,那麼基於這樣一個鏈路的話,我們怎麼設計一個鏈路工具呢?

Nginx

nginx 從 1.11.0 版本就開始內建了變數 $request_id,其原理就是生成一串 32 位的隨機字串,雖然不能比擬 uuid,但是重複的機率也很小,可以視為 uuid 來使用。使用者每次請求會生成一個 $request_id,可以作為我們的 traceId。

設定的話首先設定 nginx 日誌格式,支援 $request_id

log_format access '$remote_addr $request_time $body_bytes_sent $http_user_agent $request $status $request_id'

nginx 常用的內建變數及其含義如下:

  • $remote_addr:客戶端地址,如:172.16.11.1
  • $remote_user:客戶端使用者名稱稱
  • $time_local:訪問時間和時區,20/Dec/2022:10:47:58 +0800
  • $request:請求的URI和HTTP協議,"GET / HTTP/1.1"
  • $status:HTTP請求狀態,304
  • $body_bytes_sent:傳送給客戶端檔案內容大小
  • $request_time:整個請求的總時間
  • $request_id:當前請求的id

其次要在 nginx 轉發請求時,增加 traceId 的 header:

location / {
  proxy_set_header traceId $request_id;
}

Zuul

traceId 經過 nginx 轉發到 zuul 之後,zuul 路由轉發時存在 header 丟失的問題,我們可以自定義一個 zuul 的前置過濾器了,在過濾器中再將 header 再傳遞下去,程式碼比較簡單:

@Component
public class TraceIdPreFilter extends ZuulFilter {
    private static final String TRACE_ID = "traceId";

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 0;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() {
        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();
        requestContext.addZuulRequestHeader(TRACE_ID, request.getHeader(TRACE_ID));
        return null;
    }
}

Service 服務層

service 服務層要做幾件事:

  • 接收暫存 zuul 轉發過來的 traceId
  • 日誌檔案配置,日誌支援輸出 traceId
  • service 接收到 traceId 後,再呼叫其他服務時,需要將 traceId 繼續傳遞下去

首先我們來看下程式碼層面,如何接收暫存 zuul 轉發過來的 traceId,我們需要使用到過濾器和 MDC(放入MDC中的 key 可以在日誌中輸出)。我們建立一個過濾器,用來接收 zuul 轉發過來的 traceId,並且將 traceId 設定到 MDC 以便我們的日誌檔案可以輸出 traceId:

public class TraceIdFilter implements Filter {
    private static final String TRACE_ID = "traceId";
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        try {
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            String traceId = httpRequest.getHeader(TRACE_ID);
            TraceIdHelper.setTraceId(traceId);
            filterChain.doFilter(request, response);
        } finally {
            // 清除MDC的traceId值,確保當次請求不會影響其他請求
            TraceIdHelper.clearTraceId();
        }
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void destroy() {
    }
}

@UtilityClass
public class TraceIdHelper {
    public static final String TRACE_ID = "traceId";
    private static final ThreadLocal<String> TRACE_ID_THREAD_LOCAL = new ThreadLocal<>();

    /**
     * 設定traceId,為空時初始化一個
     * @param traceId
     */
    public void setTraceId(String traceId) {
        if (StringUtils.isBlank(traceId)) {
            traceId = UUID.randomUUID().toString();
        }
        TRACE_ID_THREAD_LOCAL.set(traceId);
        MDC.put(TRACE_ID, traceId);
    }

    /**
     * 清除traceId
     */
    public void clearTraceId() {
        TRACE_ID_THREAD_LOCAL.remove();
        MDC.remove(TRACE_ID);
    }

    /**
     * 獲取traceId
     * @return
     */
    public String getTraceId() {
        return TRACE_ID_THREAD_LOCAL.get();
    }
}

過濾器註冊:

@Configuration
public class TraceIdConfig {
    @Bean
    public FilterRegistrationBean<TraceIdFilter> loggingFilter() {
        FilterRegistrationBean<TraceIdFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new TraceIdFilter());
        // 設定過濾的URL模式
        registrationBean.addUrlPatterns("/*");
        return registrationBean;
    }
}

再來看下我們的日誌檔案配置(logback.xml):

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <property name="LOG_PATTERN"
              value="%d{yyyy-MM-dd} %d{HH:mm:ss.SSS} [%highlight(%-5level)] [%boldYellow(%X{traceId})] [%boldYellow(%thread)] %boldGreen(%logger{36} %F.%L) %msg%n">
    </property>
    <property name="FILE_LOG_PATTERN"
              value="%d{yyyy-MM-dd} %d{HH:mm:ss.SSS} [%-5level] [%X{traceId}] [%thread] %logger{36} %F.%L %msg%n">
    </property>
    <property name="FILE_PATH" value="/wls/app/applogs/service-a.%d{yyyy-MM-dd}.%i.log" />
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <appender name="FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${FILE_PATH}</fileNamePattern>
            <!-- keep 15 days' worth of history -->
            <maxHistory>15</maxHistory>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <!-- 日誌檔案的最大大小 -->
                <maxFileSize>10MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
        </rollingPolicy>
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <logger name="com.example.service.controller" level="debug"></logger>
    <root level="info">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

這裡 xml 配置檔案中配置了 traceId,這樣日誌中就能看到 traceId 被輸出了。

最後的話服務之間呼叫時,我們需要傳遞 traceId 到下一個微服務,這就要用到 feign 的攔截器:

@Component
public class TraceIdFeignInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        // spring的上下文物件
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (requestAttributes != null) {
            // 前面的過濾器已經獲取並設定了 traceId,這裡就可以直接獲取了
            requestTemplate.header(TraceIdFilter.TRACE_ID, TraceIdHelper.getTraceId());
        }
    }
}

訊息佇列層

假設 service-a 傳送一條 mq 訊息後,service-b 消費到了,那麼需要將消費鏈路也串起來怎麼做呢?我們以 rocketmq 舉例,rocketmq 提供了 UserProperty 可以傳送帶屬性的訊息,這樣透過 UserProperty 我們便能實現 traceId 的傳遞。比如訊息傳送時:

Message msg = new Message("SequenceTopicTest",// topic  
    "TagA",// tag  
    ("Hello RocketMQ " + i).getBytes("utf-8") // body  
);  
msg.putUserProperty("traceId", TraceIdHelper.getTraceId()); //設定 traceId

訊息消費時:

String traceId = msgs.get(0).getUserProperty("traceId");
TraceIdHelper.setTraceId(traceId);

dubbo 如何傳遞 traceId

dubbo 的 spi 機制可以很方便的讓我們來實現各種擴充,比如 dubbo 提供的 provider、consumer 過濾器,我們可以分別實現一個 provider、consumer 得到過濾器。

服務提供者那裡,我們可以自定義一個 consumer 過濾器,過濾器中先透過 TraceIdHelper.getTraceId() 獲取到 traceId 後再透過 dubbo 提供的 setAttachment("traceId", TraceIdHelper.getTraceId()) 將 traceId 傳遞下去。

同樣地,服務消費者那裡,我們可以自定義一個 provider 過濾器,首先透過 dubbo 提供的 getAttachment 獲取到 traceId,之後再使用封裝好的 TraceIdHelper.setTraceId 將 traceId 暫存即可,這裡程式碼就不寫了。

多執行緒時如何繼續傳遞 traceId

我們的工具類 TraceIdHelper 注意看使用的 ThreadLocal 進行的 traceId 暫存,就會存在多執行緒環境下,子執行緒取不到 traceId 也就說子執行緒的日誌沒法列印出 traceId 的問題,解決思路的話有幾種,

  • 可以自定義 ThreadPoolTaskExecutor,執行緒 run 執行前先將 traceId 設定進去,缺點是比較麻煩
  • 使用阿里提供的開源套件 TransmittableThreadLocal(使用執行緒池等會池化複用執行緒的執行元件情況下,提供ThreadLocal值的傳遞功能,解決非同步執行時上下文傳遞的問題)

總結

鏈路工具的實現會用到多個元件,每個元件都需要不同的配置:

  • nginx:配置 $request_id,轉發時配置 $request_id header
  • zuul:配置前置過濾器,進行 traceId 向下遊透傳
  • 服務層:log 日誌檔案配置,用到 MDC 來列印輸出 traceId;用到了過濾器和 Feign 攔截器來實現 traceId 透傳
  • mq:訊息佇列要想實現 traceId 傳遞,如 rocketmq 需要用到 UserProperty
  • 多執行緒:多執行緒時子執行緒可能會獲取不到 traceId,可以自定義 ThreadPoolTaskExecutor 或者 使用阿里提供的開源套件 TransmittableThreadLocal

相關文章