设计模式与链路追踪:观察者模式收集数据、装饰器增强追踪、策略模式选择采样

链路追踪,说白了就是给一次请求画一张「行程地图」。从用户点击按钮,到后端服务层层调用,再到数据库返回结果——每一步花了多少时间、出了什么错,都得清清楚楚。

我刚开始做分布式系统时,最头疼的就是排查慢请求。日志散落在几十台机器上,根本连不起来。后来接触了链路追踪,才明白这背后其实是一套设计模式的组合拳。

今天我们就聊聊,观察者模式、装饰器模式、策略模式,是怎么在链路追踪中各司其职的。

1. 观察者模式:数据采集的「广播机制」

链路追踪的第一步,是收集数据。每次服务调用、每个数据库查询、每次消息发送,都得把信息记录下来。

但问题来了——谁来收集?收集了往哪送?

我见过不少团队的做法:在每个业务方法里硬编码打日志。结果呢?日志格式不统一,采集逻辑和业务逻辑搅在一起,改个采集方式得改几十个文件。

观察者模式正好解决这个问题。它的核心思想是:被观察者(Subject)只管触发事件,观察者(Observer)负责处理事件。两者之间是松耦合的。

核心思路:把链路追踪的「数据采集点」设计成被观察者,各种数据处理器(日志写入、指标统计、异常告警)作为观察者。每次请求经过一个采集点,就广播一个事件,所有观察者各自处理。

举个例子,一个HTTP请求进来,我们可以在过滤器里触发一个事件:

// Java 示例:观察者模式在链路追踪中的应用
public class TraceEvent {
    private String traceId;
    private String spanId;
    private long startTime;
    private long endTime;
    private String serviceName;
    private String methodName;
    private Map<String, String> tags;
    // getter/setter 省略
}

// 被观察者接口
public interface TraceSubject {
    void registerObserver(TraceObserver observer);
    void removeObserver(TraceObserver observer);
    void notifyObservers(TraceEvent event);
}

// 观察者接口
public interface TraceObserver {
    void onTraceEvent(TraceEvent event);
}

// 具体被观察者:HTTP请求采集点
public class HttpTraceCollector implements TraceSubject {
    private List<TraceObserver> observers = new ArrayList<>();
    
    @Override
    public void registerObserver(TraceObserver observer) {
        observers.add(observer);
    }
    
    @Override
    public void notifyObservers(TraceEvent event) {
        for (TraceObserver observer : observers) {
            observer.onTraceEvent(event);
        }
    }
    
    // 在请求处理前后调用
    public void onRequestStart(HttpServletRequest request) {
        TraceEvent event = new TraceEvent();
        event.setTraceId(generateTraceId());
        event.setStartTime(System.currentTimeMillis());
        event.setServiceName("order-service");
        event.setMethodName(request.getRequestURI());
        notifyObservers(event);
    }
    
    public void onRequestEnd(HttpServletRequest request) {
        TraceEvent event = new TraceEvent();
        event.setEndTime(System.currentTimeMillis());
        notifyObservers(event);
    }
}

// 具体观察者:日志写入器
public class LogWriter implements TraceObserver {
    @Override
    public void onTraceEvent(TraceEvent event) {
        // 写入日志文件或发送到日志中心
        System.out.println("[TRACE] " + event.getTraceId() + 
                           " | " + event.getServiceName() + 
                           " | " + event.getMethodName() + 
                           " | " + (event.getEndTime() - event.getStartTime()) + "ms");
    }
}

// 具体观察者:指标统计器
public class MetricsCollector implements TraceObserver {
    @Override
    public void onTraceEvent(TraceEvent event) {
        // 统计接口调用次数、平均耗时等指标
        MetricsRegistry.increment(event.getServiceName() + "." + event.getMethodName());
        MetricsRegistry.recordTime(event.getServiceName() + "." + event.getMethodName(),
                                   event.getEndTime() - event.getStartTime());
    }
}

我的经验:观察者模式在链路追踪里最大的好处是「可插拔」。想加一个告警观察者?直接注册进去就行,不用改任何采集代码。我曾经在一个项目里,上线后才发现需要把追踪数据同步到Elasticsearch,加一个观察者就搞定了,完全没动业务代码。

2. 装饰器模式:给追踪「加点料」

数据采集到了,但很多时候原始数据不够用。比如,你只知道一个请求花了500ms,但不知道里面有没有重试、有没有缓存命中、有没有降级策略。

这时候就需要装饰器模式了。它的作用很直接:在不改变原有对象的情况下,动态地给对象添加额外的职责

在链路追踪里,装饰器可以用来增强追踪信息。比如,给一个普通的HTTP调用装饰上「重试次数」、「缓存状态」、「熔断标记」等额外信息。

// Java 示例:装饰器模式增强追踪信息

// 基础追踪接口
public interface TraceSpan {
    void start();
    void end();
    Map<String, String> getTags();
}

// 基础实现:一个简单的HTTP调用追踪
public class HttpTraceSpan implements TraceSpan {
    private long startTime;
    private long endTime;
    private Map<String, String> tags = new HashMap<>();
    
    @Override
    public void start() {
        startTime = System.currentTimeMillis();
        tags.put("type", "http");
    }
    
    @Override
    public void end() {
        endTime = System.currentTimeMillis();
        tags.put("duration_ms", String.valueOf(endTime - startTime));
    }
    
    @Override
    public Map<String, String> getTags() {
        return tags;
    }
}

// 装饰器基类
public abstract class TraceSpanDecorator implements TraceSpan {
    protected TraceSpan wrapped;
    
    public TraceSpanDecorator(TraceSpan wrapped) {
        this.wrapped = wrapped;
    }
    
    @Override
    public void start() {
        wrapped.start();
    }
    
    @Override
    public void end() {
        wrapped.end();
    }
    
    @Override
    public Map<String, String> getTags() {
        return wrapped.getTags();
    }
}

// 具体装饰器:添加重试信息
public class RetryDecorator extends TraceSpanDecorator {
    private int retryCount;
    
    public RetryDecorator(TraceSpan wrapped, int retryCount) {
        super(wrapped);
        this.retryCount = retryCount;
    }
    
    @Override
    public Map<String, String> getTags() {
        Map<String, String> tags = super.getTags();
        tags.put("retry_count", String.valueOf(retryCount));
        tags.put("has_retry", retryCount > 0 ? "true" : "false");
        return tags;
    }
}

// 具体装饰器:添加缓存状态
public class CacheDecorator extends TraceSpanDecorator {
    private boolean cacheHit;
    
    public CacheDecorator(TraceSpan wrapped, boolean cacheHit) {
        super(wrapped);
        this.cacheHit = cacheHit;
    }
    
    @Override
    public Map<String, String> getTags() {
        Map<String, String> tags = super.getTags();
        tags.put("cache_hit", String.valueOf(cacheHit));
        return tags;
    }
}

// 使用示例
public class TraceExample {
    public void processOrder() {
        TraceSpan span = new HttpTraceSpan();
        span = new RetryDecorator(span, 2);      // 装饰重试信息
        span = new CacheDecorator(span, true);   // 装饰缓存状态
        
        span.start();
        // ... 执行业务逻辑
        span.end();
        
        // 最终tags包含:type=http, duration_ms=xxx, retry_count=2, has_retry=true, cache_hit=true
        System.out.println(span.getTags());
    }
}

注意:装饰器模式虽然灵活,但不要滥用。我曾经见过一个项目,给一个追踪span套了七八层装饰器,结果调试的时候根本分不清哪层是哪层。我的建议是:装饰器不超过3层,如果超过,考虑用组合模式或者策略模式替代。

3. 策略模式:采样策略的「自由切换」

链路追踪有个现实问题:数据量太大了。一个高并发系统,每秒可能产生几百万条追踪记录。全量采集?存储成本扛不住,查询性能也跟不上。

所以需要采样——只采集一部分请求的追踪数据。但采样策略不是一成不变的。白天高峰期可能只采1%,晚上低峰期可以采10%。遇到异常请求,又希望全量采集。

策略模式就是干这个的。它把「采样算法」封装成独立的策略类,运行时可以自由切换。

// Java 示例:策略模式实现采样策略

// 策略接口
public interface SamplingStrategy {
    boolean shouldSample(TraceContext context);
}

// 具体策略:固定比例采样
public class FixedRateSamplingStrategy implements SamplingStrategy {
    private double rate; // 0.0 ~ 1.0
    
    public FixedRateSamplingStrategy(double rate) {
        this.rate = rate;
    }
    
    @Override
    public boolean shouldSample(TraceContext context) {
        return Math.random() < rate;
    }
}

// 具体策略:基于请求ID的哈希采样
public class HashBasedSamplingStrategy implements SamplingStrategy {
    private int sampleMod;
    
    public HashBasedSamplingStrategy(int sampleMod) {
        this.sampleMod = sampleMod;
    }
    
    @Override
    public boolean shouldSample(TraceContext context) {
        // 根据traceId的哈希值决定是否采样
        return Math.abs(context.getTraceId().hashCode()) % sampleMod == 0;
    }
}

// 具体策略:异常优先采样
public class ErrorPrioritySamplingStrategy implements SamplingStrategy {
    private double normalRate;
    private double errorRate;
    
    public ErrorPrioritySamplingStrategy(double normalRate, double errorRate) {
        this.normalRate = normalRate;
        this.errorRate = errorRate;
    }
    
    @Override
    public boolean shouldSample(TraceContext context) {
        if (context.hasError()) {
            return Math.random() < errorRate; // 异常请求采样率更高
        }
        return Math.random() < normalRate;
    }
}

// 上下文:使用策略的追踪器
public class TraceSampler {
    private SamplingStrategy strategy;
    
    public TraceSampler(SamplingStrategy strategy) {
        this.strategy = strategy;
    }
    
    public void setStrategy(SamplingStrategy strategy) {
        this.strategy = strategy;
    }
    
    public boolean decide(TraceContext context) {
        return strategy.shouldSample(context);
    }
}

// 使用示例
public class SamplingExample {
    public void configureSampling() {
        TraceSampler sampler = new TraceSampler(new FixedRateSamplingStrategy(0.01));
        
        // 白天高峰期:1%采样
        sampler.setStrategy(new FixedRateSamplingStrategy(0.01));
        
        // 晚上低峰期:10%采样
        sampler.setStrategy(new FixedRateSamplingStrategy(0.1));
        
        // 遇到异常时:50%采样
        sampler.setStrategy(new ErrorPrioritySamplingStrategy(0.01, 0.5));
    }
}

避坑指南:我曾经犯过一个错误——在运行时频繁切换采样策略。结果导致同一批请求的采样结果不一致,有的span被采样了,有的没被采样,链路断成了好几截。后来我改成「在请求入口处一次性决定是否采样」,然后把采样标记通过上下文传递下去,问题就解决了。

4. 三者如何协同工作?

这三个模式不是孤立的。在实际的链路追踪系统中,它们是这样配合的:

  1. 策略模式在请求入口处决定「要不要采这个请求」
  2. 观察者模式在请求经过的每个节点收集数据,广播事件
  3. 装饰器模式在数据收集过程中,给每个span添加额外的上下文信息

我画了一张图,帮你理清它们的关系:

设计模式在链路追踪中的协同工作 请求入口 策略模式 选择采样策略 观察者模式 收集数据 · 广播事件 (日志写入器、指标统计器、告警器) 装饰器模式 增强追踪信息 (重试信息、缓存状态、熔断标记) 决定是否采集 传递上下文 数据存储(日志/指标/告警) 策略模式:决定采样 观察者模式:收集数据 装饰器模式:增强信息

你看,这三个模式各司其职,又互相配合。策略模式做决策,观察者模式做采集,装饰器模式做增强。它们共同构成了一个灵活、可扩展的链路追踪系统。

5. 实际项目中的取舍

说了这么多理论,最后聊聊实际落地时的一些经验。

观察者模式的性能问题:如果观察者太多,每次事件广播都会遍历整个列表。我建议用「异步观察者」——把事件丢到队列里,让后台线程去处理。这样不会阻塞主流程。

装饰器模式的调试问题:装饰器嵌套多了,调试时很难定位问题。我的习惯是给每个装饰器加一个「名称」属性,在日志里打印出来,方便追踪。

策略模式的动态切换:不要频繁切换策略。最好在配置中心里设置,通过监听配置变更来切换。而且切换时要保证「正在处理的请求」不受影响。

总结一句话:链路追踪不是单一技术,而是设计模式的组合艺术。观察者模式让你「采得到」,装饰器模式让你「采得全」,策略模式让你「采得巧」。三者配合,才能构建一个生产级的链路追踪系统。


公众号:蓝海资料掘金营,微信deep3321