likes
comments
collection
share

秒杀 : 做一个完善的全链路日志实现方案有多简单

作者站长头像
站长
· 阅读数 9

👈👈👈 欢迎点赞收藏关注哟

一. 前言

全链路日志不算秒杀的特性,是分布式项目里面的基础需求。不过既然想说清楚整个流程,那这个也有必要聊清楚。

一句话精髓 :生成一个唯一的 TraceId , 通过技术手段在全链路中把这个 TraceId 打印到日志中,再通过日志收集工具对日志收集

二. 宏观流程

秒杀 : 做一个完善的全链路日志实现方案有多简单

  • 核心方式就是生成唯一的 TraceId ,从上游取出,再传递到下游
  • 实现细节是通过日志框架的 MDC 功能
  • 外部依赖就是日志收集和分析,例如 ES 处理,或者阿里的 SLS 日志收集
  • 如果想统计日志到一个集合里面也是可以实现,但是没有必要

三. 技术重点

3.1 MDC 技术

MDC 是一种日志记录的概念,在常见的日志框架里面都有实现,以日志接口 logback 为例,MDC 主要涉及以下几个类 :

S1 : 配置 logback 配置文件

<?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 : 在代码中写入和获取变量

// 设置 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 ,也可以避免不同应用重复。

// 1. 调用方设置 traceId 到 Header 头中(具体发送就不说了,都是一个样)
HeaderBuilder headerBuilder = null;
headerBuilder.addHeader("traceId", UUID.randomUUID().toString().replace("-", "").toUpperCase());

// 2. 网关转发中进行二次处理
- 为了避免多个系统同时调用时出现重复,网关中可能需要进行二次封装
- 如果不需要二次调用,网关的 Header 头直接下发到服务即可

基于这种方式可以帮助我们在跨系统调用时追踪到对应的调用栈。

S2 : 微服务之间的串联

这个操作和网关的调用类型,主要的目的是把 Header 头的数据进行传递 。

  • 在发起远程调用 (Feign)时,在调用的请求头中加入 TraceId
  • 在接收远程调用的时候,从 Header 头中取出 TraceId 后写入 MDC
// 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!!!


// 发送端方式一 :传递 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 : 注意线程的使用

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 中进行配置

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 ),上面这一篇文档对于这些写的更详细,比我专业,我就不多说了。

总结

搞定收工,全链路日志其实很简单。

这一篇是秒杀的第二篇,争取今年把秒杀概率全部写完,算是给这几年的学习给个答案。

关联 :