👈👈👈 欢迎点赞收藏关注哟
首先分享之前的所有文章 >>>> 😜😜😜
文章合集 : 🎁 juejin.cn/post/694164...
Github : 👉 github.com/black-ant
CASE 备份 : 👉 gitee.com/antblack/ca...
一. 前言
全链路日志不算秒杀的特性,是分布式项目里面的基础需求。不过既然想说清楚整个流程,那这个也有必要聊清楚。
一句话精髓 :生成一个唯一的 TraceId , 通过技术手段在全链路中把这个 TraceId 打印到日志中,再通过日志收集工具对日志收集。
二. 宏观流程
- 核心方式就是生成唯一的 TraceId ,从上游取出,再传递到下游
- 实现细节是通过日志框架的 MDC 功能
- 外部依赖就是日志收集和分析,例如 ES 处理,或者阿里的 SLS 日志收集
- 如果想统计日志到一个集合里面也是可以实现,但是没有必要
三. 技术重点
3.1 MDC 技术
MDC 是一种日志记录的概念,在常见的日志框架里面都有实现,以日志接口 logback 为例,MDC 主要涉及以下几个类 :
S1 : 配置 logback 配置文件
java
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 定义控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{TraceId}] %-5level %logger{35} - %msg%n</pattern>
</encoder>
</appender>
<!-- 定义根日志级别为INFO -->
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
其中核心的点就是关键字 : %X{TraceId},通过这样的写法可以获取到我们往 MDC 域中写入的值。
S2 : 在代码中写入和获取变量
java
// 设置 MDC 参数
MDC.put("TraceId", UUID.randomUUID().toString());
mdcService.mdcInfo();
// 清空 MDC 参数
MDC.clear();
mdcService.mdcInfo();
// ----- 跨类调用后
// 打印和获取 MDC 参数
log.info("MDC Info");
log.info(MDC.get("TraceId"));
// 打印的日志 :
... [...] [ce8eff5e-c928-4bc0-b3db-0603cec0c0b3] INFO .....MdcService - MDC Info
... [...] [ce8eff5e-c928-4bc0-b3db-0603cec0c0b3] INFO .....MdcService - ce8eff5e-c928-4bc0-b3db-0603cec0c0b3
// clear 后打印的日志
2023-09-24 18:35:32.557 [http-nio-8080-exec-1] [] INFO com.gang.test.controller.MdcService - MDC Info
2023-09-24 18:35:32.557 [http-nio-8080-exec-1] [] INFO com.gang.test.controller.MdcService - null
- 在打印的日志中 ,MDC 作为模板的统一变量被替换
- 可以通过 get 方法获取到 mdc 的变量
3.2 需要串联的节点
虽然我们保证了一个简单的流程里面全链路 ID得到了串联,但是使用的时候就会发现有很多节点断开了,在分析业务问题的时候也会带来不少的麻烦
容易断开的点主要包括以下几个 :
- gateway : 外部调用方与主系统如何发生串联
- 微服务 :微服务调用之间怎么进行串联
- mq : 基于 MQ 实现了消费队列后,数据怎么进行串联
- 定时任务 : 定时任务有没有办法进行串联
S1 : 用网关把调用方与主系统的串联
如果有一个好的架构体系这一块是很简单的,只需要调用方按照需求往 Header 中传递关键字,然后在网关中进行获取即可。
如果系统架构迥异,则可以提供一个 SDK 给上游调用, SDK 中底层把 traceId ,也可以避免不同应用重复。
java
// 1. 调用方设置 traceId 到 Header 头中(具体发送就不说了,都是一个样)
HeaderBuilder headerBuilder = null;
headerBuilder.addHeader("traceId", UUID.randomUUID().toString().replace("-", "").toUpperCase());
// 2. 网关转发中进行二次处理
- 为了避免多个系统同时调用时出现重复,网关中可能需要进行二次封装
- 如果不需要二次调用,网关的 Header 头直接下发到服务即可
基于这种方式可以帮助我们在跨系统调用时追踪到对应的调用栈。
S2 : 微服务之间的串联
这个操作和网关的调用类型,主要的目的是把 Header 头的数据进行传递 。
- 在发起远程调用 (Feign)时,在调用的请求头中加入 TraceId
- 在接收远程调用的时候,从 Header 头中取出 TraceId 后写入 MDC
java
// 1. 发起调用时写入 Header
public class CustomFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 添加请求头信息,从MDC中获取到链路 ID = MDC.get("TraceId")
template.header("traceId", "you traceId");
}
}
// 2. 接收调用时处理
@Component
public class CustomHeaderFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 获取HttpServletRequest对象
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 获取请求头信息
String customHeader = httpRequest.getHeader("traceId");
MDC.put("traceId", customHeader);
// 继续请求链
chain.doFilter(request, response);
}
到了这里远程调用就整合完成了。
S3 : MQ 发起的调用
MQ 由于不是Rest调用,所以流程会稍微有点不一样 , 以 RocketMQ 为例 :
- RocketMQ 中可以定义两个标识符 :
- MsgKey : 业务标识符,可以由生产者自己生成,用于标识业务的唯一性,可以用于业务幂等
- MsgId : RocketMQ 为每条消息生成的 ID ,不需要配置,用于 RocketMQ 内部做唯一性相关处理
- Propterties : 一个 Map 集合,用于在消息中传递额外的信息
- 实现方案 👉👉👉:
- 在 TraceId 能保证唯一的情况下,可以直接使用 MsgKey 进行配置
- 在 TraceId 不能唯一的情况下,需要借助 Propterties
其实 MsgKey 最终还是走的 properties!!!
java
// 发送端方式一 :传递 MsgKey
Message message = new Message("TopicTest", "TagA", "you-MessageKey", "Hello, world!".getBytes());
producer.send(message);
// 发送端方式二 :设置自定义 Propertiess 进行传递
message2.putUserProperty("traceId", MDC.get("traceId"));
// 接收端方式一 :获取 MsgKey
public class ConsumerExample {
public static void main(String[] args) throws Exception {
// 注册消息监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> messages, ConsumeConcurrentlyContext context) {
for (MessageExt message : messages) {
// 获取消息键
String messageKey = message.getKeys();
// 获取 Properties
message.getProperties().get("traceId")
MDC.put("traceId",messageKey)
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者
consumer.start();
}
}
其他的框架思路类似,大同小异
S4 : 注意线程的使用
java
public class LogbackMDCAdapter implements MDCAdapter {
final ThreadLocal<Map<String, String>> copyOnThreadLocal = new ThreadLocal();
}
大多数日志框架都是基于 ThreadLocal 进行的数据暂存,所以一定要考虑多线程的影响。
例如使用线程池的情况下,可能要从主线程里面把TraceId 取出使用。
S5 : 关于定时任务
如果是 Quartz 这类框架,因为是基于业务搭建的,所以在存储侧多写几个参数是没什么问题的
但是如果是 xxl-job 这类框架,就不好容易实现了
四. 硬件整合
上文我们只是把日志在整个调用的链路中进行了串联,后续的硬件也要配套跟上,总的思路如下 :
- S1 : 将日志透挂载到指定目录 (如果是容器则需要映射路径到物理机中)
- S2 : 收集日志到日志分析系统中
- 开源的组件可以选择 ELK ,通过 Logstash 也可以直接收集到容器的日志
- 如果是阿里系可以选择 K8S + SLS 的体系,让 SLS 抓取指定路径的日志
- 华为云则是 LTS 云日志系统
SLS 的比较简单,配置好路径后直接在 SLS 里面创建就行,这种一般找官方就有支持。
用开源的 ELK 自己要搞得就有点多了,需要自己映射日志名称,总的来说都是在 LogStash 中进行配置
java
input {
file {
path => "/var/log/nginx/access.log"
start_position => "beginning"
type => "nginx_access" # 设置日志集合名称为 "nginx_access"
}
}
这一块我也是个菜鸟,这里就不详述了
五. 局限和难点
以上是一个链路日志的基础方案,但是还是有很多的局限性,针对这些局限性业界其实也有相关的方案 :
- 无法对日志的调用链有个明确的的调用链
- 解决方案 :在日志 MDC 中额外再记录调用层级和上游,对链路进行染色
- 发生回调或者异步等场景时容易丢失流程 :
- 解决方案 : 链路存储,把链路的信息在三方处进行关联和映射
- 同一个业务的2个不同处理阶段没有串联(例如充钱送钱,属于一个业务,但是很可能是2个请求进来的)
- 解决方案 :关键字进行串联,染色,上报
@ tech.meituan.com/2022/07/21/...
其实用的时候有的可以通过传更多的参数进行优化,有的可以通过其他的方式进行解决(例如 Skywalking ,ARMS ),上面这一篇文档对于这些写的更详细,比我专业,我就不多说了。
总结
搞定收工,全链路日志其实很简单。
这一篇是秒杀的第二篇,争取今年把秒杀概率全部写完,算是给这几年的学习给个答案。