リンク追跡が必要な理由
なぜリンク追跡が必要なのでしょうか? マイクロサービス環境では、サービスが相互に呼び出し、A->B->C->D->C などの複雑なサービス相互作用が発生する可能性があります。その場合、リクエスト リンクを完全に記録するメソッドが必要です。そうでない場合は、トラブルシューティングが困難です。問題を特定しても、リクエスト ログを完全につなぎ合わせることができません。
リンクトラッキングを実装する方法
ユーザー リクエスト インターフェイスから始めるとします。各リクエストには、リクエストを識別するための一意のリクエスト ID (当面は、traceId としてマークします) が必要です。その後、インターフェイスがリクエストを受信し、後続のサービスまたは mq を呼び出すと、常にtraceIdをPass it onでき、ログを印刷できるため、簡易的な連携機能を実現できます。
リクエストごとに一意の requestId を生成する方法
マイクロサービス環境では、通常、ユーザーのリクエストは最初にゲートウェイを通過し、その後ゲートウェイがリクエストを各サービスに転送します。Nginx、Zuul、Spring Cloud Gateway、Kong、Traefik など、さまざまなマイクロサービス ゲートウェイがあります。ユーザー リクエスト -> nginx -> zuul -> service-a、service-b などのリンクがあるとします。 (ここでは、サービス登録センターとして Eureka を使用し、マイクロサービス間の相互呼び出しを実装するために Feign を使用し、サービス フロントエンド ゲートウェイとして Zuul を使用します。) 呼び出しはおおよそ次のとおりです。
この場合、nginx リクエストから開始して、このリクエストの TraceId を特定する必要があり、その後、TraceId をサービス サービス層に渡すことができるため、そのようなリンクに基づいて、リンク ツールをどのように設計すればよいでしょうか?
Nginx
nginx にはバージョン 1.11.0 から変数が組み込まれており $request_id
、32 ビットのランダムな文字列を生成するという原理があり、uuid とは比較できませんが、繰り返しの確率が非常に小さいため、uuid として使用できます。ユーザーリクエストごとに 1 つが生成され $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 ヘッダーを追加します。
location / {
|
|
proxy_set_header traceId $request_id; |
|
} |
ズール
TraceId が nginx 経由で zuul に転送された後、zuul がルート転送するときにヘッダー損失の問題が発生します。zuul プレフィルターをカスタマイズしてフィルターにヘッダーを渡すことができます。コードは比較的単純です:
@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; |
|
} |
|
} |
サービスサービス層
サービス サービス層はいくつかのことを行う必要があります。
- 一時的なzuulによって転送されたtraceIdを受信します
- ログファイル構成、ログは出力traceIdをサポートします
- サービスが TraceId を受け取った後、他のサービスを呼び出すときに、TraceId を渡す必要があります。
まずはコードレベルで、一時保存先zuulから転送されたtraceIdを受け取るにはフィルターとMDCを使う必要があります(MDCに置いたキーはログに出力できます)。zuul によって転送された TraceId を受信するフィルターを作成し、ログ ファイルが TraceId を出力できるように、TraceId を MDC に設定します。
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> |
トレース ID は XML 構成ファイルで構成されているため、出力されるログにトレース ID が表示されます。
最後に、サービス間で呼び出しを行う場合は、次のマイクロサービスに 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