Service Mesh 访问日志采样全量记录成本与采样的盲区一、全量记录访问日志你的存储账单崩了没有上 Service Mesh 后一个几乎没人提前算的账就是访问日志的存储量。一个中等规模的微服务集群日均百亿次调用每次调用产生一条 Sidecar 级别的 access log。一条日志按 500 字节算一天就是 50GB一个月 1.5TB。这还没算索引膨胀和副本开销。大多数团队的第一反应是开采样。问题来了——采样率设多少10%1%0.1%采样率太低低频但致命的错误请求直接被丢掉。采样率太高成本又压不住。更隐蔽的问题是均匀随机采样对故障排查几乎没用。你需要的是能保留下一次 P0 事故所需的关键证据而随机采样做不到这一点。二、底层机制与原理剖析EnvoyIstio 数据面的实现的访问日志生成链路如下Envoy 的访问日志本身不支持复杂的采样逻辑它只提供最基础的写与不写。真正灵活的多级采样需要在 access log serviceALS层实现。核心思路是分层采样不同层级用不同策略第一层高优先级全量保留。5xx 错误、连接重置、鉴权失败等异常请求必须 100% 保留。这些是排障的核心证据。第二层条件概率采样。4xx 错误、延迟超过 P99 的请求按较高比例20%–50%保留。这些是潜在问题的预警信号。第三层低优先级稀疏采样。2xx 成功且延迟正常的请求按 1% 甚至更低比例保留。主要用于流量画像统计不用于排障。这样分级后存储量可以从全量的 50GB/天骤降到 500MB/天以下同时保证关键故障证据不丢失。三、生产级代码实现下面是一个访问日志采样服务的 core logic。处理 Envoy 通过 gRPC 发送的 ALS 消息执行分层采样后再写入下游存储。package als import ( context crypto/rand encoding/binary math sync time github.com/prometheus/client_golang/prometheus go.uber.org/zap ) // SampleStrategy 采样策略接口 type SampleStrategy interface { ShouldSample(entry *AccessLogEntry) bool Name() string } // AccessLogEntry 访问日志条目简化版仅保留采样所需字段 type AccessLogEntry struct { StatusCode int LatencyMs float64 RequestPath string Method string UpstreamHost string ResponseFlag string // Envoy 的响应标志如 UH (no healthy upstream) TraceID string Timestamp time.Time } // --- 策略一错误全量保留 --- type ErrorSampler struct{} func (s *ErrorSampler) ShouldSample(entry *AccessLogEntry) bool { // 5xx 和连接级错误必须 100% 保留 return entry.StatusCode 500 || entry.ResponseFlag ! } func (s *ErrorSampler) Name() string { return error_sampler } // --- 策略二条件概率采样 --- type ProbabilitySampler struct { rate float64 // 采样率 0-1 condition func(*AccessLogEntry) bool name string } func NewProbabilitySampler(name string, rate float64, condition func(*AccessLogEntry) bool) *ProbabilitySampler { return ProbabilitySampler{name: name, rate: rate, condition: condition} } func (s *ProbabilitySampler) ShouldSample(entry *AccessLogEntry) bool { if !s.condition(entry) { return false } // 使用 crypto/rand 而非 math/rand避免并发安全问题 var b [8]byte _, err : rand.Read(b[:]) if err ! nil { return false // 随机数生成失败保守策略不采样 } // 将 8 字节转为 uint64取模判断 n : binary.BigEndian.Uint64(b[:]) return float64(n)/float64(math.MaxUint64) s.rate } func (s *ProbabilitySampler) Name() string { return s.name } // --- 策略三基于 TraceID 的一致性采样 --- type TraceIDConsistentSampler struct { rate float64 } func (s *TraceIDConsistentSampler) ShouldSample(entry *AccessLogEntry) bool { if entry.TraceID { return false } // 取 TraceID 的确定性 hash保证同一个 TraceID 在所有服务节点的采样决策一致 hash : hashTraceID(entry.TraceID) return float64(hash%10000)/10000.0 s.rate } func (s *TraceIDConsistentSampler) Name() string { return trace_id_consistent } func hashTraceID(traceID string) uint64 { h : uint64(0) for _, c : range traceID { h h*31 uint64(c) } return h } // --- 采样链路编排 --- // LayeredSampler 分层采样器 // 设计决策短路的优先级链一旦某层判定采样就立即保留 type LayeredSampler struct { strategies []SampleStrategy logger *zap.Logger mu sync.RWMutex // Prometheus 指标 sampleTotal *prometheus.CounterVec sampleDropped prometheus.Counter sampleRetained prometheus.Counter } func NewLayeredSampler(strategies []SampleStrategy, logger *zap.Logger) *LayeredSampler { s : LayeredSampler{ strategies: strategies, logger: logger, sampleTotal: prometheus.NewCounterVec( prometheus.CounterOpts{ Name: als_samples_total, Help: Total access log entries processed, }, []string{strategy, decision}, ), sampleDropped: prometheus.NewCounter(prometheus.CounterOpts{ Name: als_samples_dropped_total, }), sampleRetained: prometheus.NewCounter(prometheus.CounterOpts{ Name: als_samples_retained_total, }), } // prometheus.MustRegister(s.sampleTotal, s.sampleDropped, s.sampleRetained) return s } // Decide 决定一条日志是否保留 // 返回 (是否保留, 命中策略名) func (s *LayeredSampler) Decide(ctx context.Context, entry *AccessLogEntry) (bool, string) { // 按策略优先级顺序判定 for _, strategy : range s.strategies { if strategy.ShouldSample(entry) { s.sampleTotal.WithLabelValues(strategy.Name(), retained).Inc() s.sampleRetained.Inc() return true, strategy.Name() } } s.sampleTotal.WithLabelValues(none, dropped).Inc() s.sampleDropped.Inc() return false, } // ReloadStrategies 热更新采样策略运行时调整采样率无需重启 func (s *LayeredSampler) ReloadStrategies(strategies []SampleStrategy) { s.mu.Lock() defer s.mu.Unlock() s.strategies strategies s.logger.Info(sampling strategies reloaded, zap.Int(count, len(strategies))) } // 构建生产级采样链路 func BuildDefaultLayeredSampler(logger *zap.Logger) *LayeredSampler { strategies : []SampleStrategy{ // 层级1错误全量保留 ErrorSampler{}, // 层级2高延迟请求 20% 采样 NewProbabilitySampler(high_latency, 0.2, func(e *AccessLogEntry) bool { return e.LatencyMs 500 // P99 阈值需要根据实际流量校准 }), // 层级34xx 错误 10% 采样 NewProbabilitySampler(client_error, 0.1, func(e *AccessLogEntry) bool { return e.StatusCode 400 e.StatusCode 500 }), // 层级4基于 TraceID 的一致性采样 1% TraceIDConsistentSampler{rate: 0.01}, } return NewLayeredSampler(strategies, logger) }四、边界分析与架构权衡分层采样的缺点层级多了维护成本上升。尤其是第二层的高延迟阈值需要跟业务真实 P99 对齐水位一变阈值就得跟着调。另外基于 TraceID 的一致性采样依赖上游传入了有效的 TraceID——如果 Tracing 链路本身不完整这层采样就是废的。适用边界这套方案适合日均调用量在千万级以上的集群。调用量太小的集群直接全量记录就行没必要上采样。同时也要求团队已经接入了 OpenTelemetry 或类似的 Tracing 体系TraceID 能在全链路传递。禁用场景不要用随机均匀采样替代分层采样尤其是在排查低频错误时——0.01% 概率触发的 bug均匀采样 1% 大概率什么都抓不到。另外金融、合规类场景可能要求全量日志留存分层采样不适用。五、总结访问日志的存储成本不是简单地调低采样率就能解决的。分层采样的核心思路是把存储配额优先分配给真正有价值的日志——错误、高延迟、异常模式。用好 ErrorSampler 兜底、概率采样做信号增强、TraceID 保证链路完整性三位一体的策略才能在成本和排障能力之间找到平衡点。