Skip to main content

sz_orm_observability/
slo.rs

1//! SLO 燃烧率监控
2//!
3//! 基于 Google SRE 实践(Google SRE Workbook 第 5 章),使用多窗口多燃烧率告警策略:
4//!
5//! v0.2.2 修复 P2-1:从 2 窗口扩展为 4 窗口(标准 Google SRE 多窗口燃烧率告警):
6//!
7//! - **Page 告警**(短期,立即触发):1h 长窗口 + 5m 短窗口,阈值 14.4x(2% 预算/小时)
8//! - **Ticket 告警**(长期,工单系统):6h 长窗口 + 30m 短窗口,阈值 6x(5% 预算/6 小时)
9//!
10//! 燃烧率 = 实际错误率 / 允许错误率
11//!
12//! 当燃烧率超过阈值时,表示 SLO 预算正在被快速消耗。
13//!
14//! # 多窗口告警原理
15//!
16//! 单一窗口告警会有误报或漏报:
17//! - 仅长窗口:响应过慢,故障已影响用户才告警
18//! - 仅短窗口:易因瞬时抖动误报
19//!
20//! 多窗口策略:短窗口和长窗口都超过阈值才告警,平衡灵敏度与准确度。
21//!
22//! # 示例
23//!
24//! ```
25//! use sz_orm_observability::{SloConfig, SloMonitor};
26//! use std::time::Duration;
27//!
28//! // 默认配置即为 Google SRE 推荐:4 窗口 + Page/Ticket 双告警
29//! let config = SloConfig::default();
30//! let monitor = SloMonitor::new(config);
31//!
32//! // 记录请求结果
33//! monitor.record_success();
34//! monitor.record_failure();
35//!
36//! // 获取燃烧率(包含 Page 和 Ticket 两级告警)
37//! let rate = monitor.burn_rate();
38//! println!("Page alerting: {}, Ticket alerting: {}",
39//!     rate.page_alerting, rate.ticket_alerting);
40//! ```
41
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::Arc;
44use std::time::{Duration, Instant};
45
46use parking_lot::RwLock;
47
48/// SLO 配置
49///
50/// v0.2.2 修复 P2-1:扩展为 4 窗口(Page + Ticket 两级告警)。
51#[derive(Debug, Clone)]
52pub struct SloConfig {
53    /// 目标成功率(如 0.999 表示 99.9%)
54    pub target_success_rate: f64,
55
56    // ==================== Page 告警(短期,立即触发)====================
57    /// Page 告警的长窗口时长(如 1 小时)
58    ///
59    /// 兼容旧字段:等价于 `page_long_window`。Google SRE 推荐 1 小时。
60    pub long_window: Duration,
61    /// Page 告警的短窗口时长(如 5 分钟)
62    ///
63    /// 兼容旧字段:等价于 `page_short_window`。Google SRE 推荐 5 分钟。
64    pub short_window: Duration,
65    /// Page 告警的燃烧率阈值(如 14.4 表示 1 小时消耗 2% 预算)
66    ///
67    /// 兼容旧字段:等价于 `page_burn_rate_threshold`。
68    pub burn_rate_threshold: f64,
69
70    // ==================== Ticket 告警(长期,工单系统)====================
71    /// Ticket 告警的长窗口时长(如 6 小时)
72    pub ticket_long_window: Duration,
73    /// Ticket 告警的短窗口时长(如 30 分钟)
74    pub ticket_short_window: Duration,
75    /// Ticket 告警的燃烧率阈值(如 6.0 表示 6 小时消耗 5% 预算)
76    pub ticket_burn_rate_threshold: f64,
77}
78
79impl Default for SloConfig {
80    fn default() -> Self {
81        // Google SRE Workbook 第 5 章推荐配置
82        Self {
83            target_success_rate: 0.999,
84            // Page 告警:1h + 5m,14.4x(2% 预算/小时)
85            long_window: Duration::from_secs(3600),
86            short_window: Duration::from_secs(300),
87            burn_rate_threshold: 14.4,
88            // Ticket 告警:6h + 30m,6.0x(5% 预算/6 小时)
89            ticket_long_window: Duration::from_secs(6 * 3600),
90            ticket_short_window: Duration::from_secs(30 * 60),
91            ticket_burn_rate_threshold: 6.0,
92        }
93    }
94}
95
96/// SLO 燃烧率快照
97#[derive(Debug, Clone)]
98pub struct SloBurnRate {
99    /// Page 短窗口实际成功率
100    pub short_success_rate: f64,
101    /// Page 长窗口实际成功率
102    pub long_success_rate: f64,
103    /// Page 短窗口燃烧率
104    pub short_burn_rate: f64,
105    /// Page 长窗口燃烧率
106    pub long_burn_rate: f64,
107    /// 剩余 SLO 预算(0.0-1.0)
108    pub error_budget_remaining: f64,
109    /// Page 告警是否触发(兼容旧字段,等价于 `page_alerting`)
110    pub alerting: bool,
111
112    // ==================== v0.2.2 修复 P2-1:Ticket 告警字段 ====================
113    /// Ticket 短窗口实际成功率
114    pub ticket_short_success_rate: f64,
115    /// Ticket 长窗口实际成功率
116    pub ticket_long_success_rate: f64,
117    /// Ticket 短窗口燃烧率
118    pub ticket_short_burn_rate: f64,
119    /// Ticket 长窗口燃烧率
120    pub ticket_long_burn_rate: f64,
121    /// Page 告警是否触发(短期故障,立即通知)
122    pub page_alerting: bool,
123    /// Ticket 告警是否触发(长期故障,工单系统)
124    pub ticket_alerting: bool,
125}
126
127impl Default for SloBurnRate {
128    fn default() -> Self {
129        Self {
130            short_success_rate: 1.0,
131            long_success_rate: 1.0,
132            short_burn_rate: 0.0,
133            long_burn_rate: 0.0,
134            error_budget_remaining: 1.0,
135            alerting: false,
136            ticket_short_success_rate: 1.0,
137            ticket_long_success_rate: 1.0,
138            ticket_short_burn_rate: 0.0,
139            ticket_long_burn_rate: 0.0,
140            page_alerting: false,
141            ticket_alerting: false,
142        }
143    }
144}
145
146impl std::fmt::Display for SloBurnRate {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        write!(
149            f,
150            "SloBurnRate{{page[short={:.4}/{:.2}x, long={:.4}/{:.2}x, alert={}], ticket[short={:.4}/{:.2}x, long={:.4}/{:.2}x, alert={}], budget={:.2}%}}",
151            self.short_success_rate,
152            self.short_burn_rate,
153            self.long_success_rate,
154            self.long_burn_rate,
155            self.page_alerting,
156            self.ticket_short_success_rate,
157            self.ticket_short_burn_rate,
158            self.ticket_long_success_rate,
159            self.ticket_long_burn_rate,
160            self.ticket_alerting,
161            self.error_budget_remaining * 100.0,
162        )
163    }
164}
165
166/// 基于时间窗口的请求计数器
167struct WindowedCounter {
168    window: Duration,
169    success: Arc<AtomicU64>,
170    failure: Arc<AtomicU64>,
171    window_start: Arc<RwLock<Instant>>,
172}
173
174impl WindowedCounter {
175    fn new(window: Duration) -> Self {
176        Self {
177            window,
178            success: Arc::new(AtomicU64::new(0)),
179            failure: Arc::new(AtomicU64::new(0)),
180            window_start: Arc::new(RwLock::new(Instant::now())),
181        }
182    }
183
184    fn record_success(&self) {
185        self.rotate_if_needed();
186        self.success.fetch_add(1, Ordering::Relaxed);
187    }
188
189    fn record_failure(&self) {
190        self.rotate_if_needed();
191        self.failure.fetch_add(1, Ordering::Relaxed);
192    }
193
194    fn rotate_if_needed(&self) {
195        let start = self.window_start.read();
196        if start.elapsed() >= self.window {
197            drop(start);
198            let mut start = self.window_start.write();
199            // 双重检查
200            if start.elapsed() >= self.window {
201                // 重置计数器
202                self.success.store(0, Ordering::Relaxed);
203                self.failure.store(0, Ordering::Relaxed);
204                *start = Instant::now();
205            }
206        }
207    }
208
209    fn success_rate(&self) -> f64 {
210        let s = self.success.load(Ordering::Relaxed);
211        let f = self.failure.load(Ordering::Relaxed);
212        let total = s + f;
213        if total == 0 {
214            return 1.0;
215        }
216        s as f64 / total as f64
217    }
218}
219
220/// SLO 监控器
221///
222/// v0.2.2 修复 P2-1:扩展为 4 窗口(Page + Ticket 两级告警)。
223pub struct SloMonitor {
224    config: SloConfig,
225    // Page 告警窗口(短期)
226    page_short_counter: WindowedCounter,
227    page_long_counter: WindowedCounter,
228    // Ticket 告警窗口(长期)
229    ticket_short_counter: WindowedCounter,
230    ticket_long_counter: WindowedCounter,
231    /// 累计成功数(自启动以来)
232    total_success: AtomicU64,
233    /// 累计失败数(自启动以来)
234    total_failure: AtomicU64,
235}
236
237impl SloMonitor {
238    /// 创建新监控器
239    pub fn new(config: SloConfig) -> Self {
240        let page_short_counter = WindowedCounter::new(config.short_window);
241        let page_long_counter = WindowedCounter::new(config.long_window);
242        let ticket_short_counter = WindowedCounter::new(config.ticket_short_window);
243        let ticket_long_counter = WindowedCounter::new(config.ticket_long_window);
244        Self {
245            config,
246            page_short_counter,
247            page_long_counter,
248            ticket_short_counter,
249            ticket_long_counter,
250            total_success: AtomicU64::new(0),
251            total_failure: AtomicU64::new(0),
252        }
253    }
254
255    /// 记录一次成功
256    pub fn record_success(&self) {
257        self.page_short_counter.record_success();
258        self.page_long_counter.record_success();
259        self.ticket_short_counter.record_success();
260        self.ticket_long_counter.record_success();
261        self.total_success.fetch_add(1, Ordering::Relaxed);
262    }
263
264    /// 记录一次失败
265    pub fn record_failure(&self) {
266        self.page_short_counter.record_failure();
267        self.page_long_counter.record_failure();
268        self.ticket_short_counter.record_failure();
269        self.ticket_long_counter.record_failure();
270        self.total_failure.fetch_add(1, Ordering::Relaxed);
271    }
272
273    /// 当前燃烧率
274    pub fn burn_rate(&self) -> SloBurnRate {
275        // 触发窗口轮转检查(即使无新请求也能轮转)
276        self.page_short_counter.rotate_if_needed();
277        self.page_long_counter.rotate_if_needed();
278        self.ticket_short_counter.rotate_if_needed();
279        self.ticket_long_counter.rotate_if_needed();
280
281        // Page 窗口
282        let page_short_rate = self.page_short_counter.success_rate();
283        let page_long_rate = self.page_long_counter.success_rate();
284
285        // Ticket 窗口
286        let ticket_short_rate = self.ticket_short_counter.success_rate();
287        let ticket_long_rate = self.ticket_long_counter.success_rate();
288
289        let allowed_error_rate = 1.0 - self.config.target_success_rate;
290
291        let page_short_error = 1.0 - page_short_rate;
292        let page_long_error = 1.0 - page_long_rate;
293        let ticket_short_error = 1.0 - ticket_short_rate;
294        let ticket_long_error = 1.0 - ticket_long_rate;
295
296        let page_short_burn = if allowed_error_rate > 0.0 {
297            page_short_error / allowed_error_rate
298        } else {
299            0.0
300        };
301        let page_long_burn = if allowed_error_rate > 0.0 {
302            page_long_error / allowed_error_rate
303        } else {
304            0.0
305        };
306        let ticket_short_burn = if allowed_error_rate > 0.0 {
307            ticket_short_error / allowed_error_rate
308        } else {
309            0.0
310        };
311        let ticket_long_burn = if allowed_error_rate > 0.0 {
312            ticket_long_error / allowed_error_rate
313        } else {
314            0.0
315        };
316
317        // 剩余预算
318        let total_success = self.total_success.load(Ordering::Relaxed);
319        let total_failure = self.total_failure.load(Ordering::Relaxed);
320        let total = total_success + total_failure;
321        let error_budget_remaining = if total == 0 {
322            1.0
323        } else {
324            let actual_error_rate = total_failure as f64 / total as f64;
325            let consumed = (actual_error_rate / allowed_error_rate).min(1.0);
326            1.0 - consumed
327        };
328
329        // v0.2.2 修复 P2-1:多窗口告警策略
330        //
331        // Page 告警:page_short 和 page_long 都超过 page_threshold
332        // Ticket 告警:ticket_short 和 ticket_long 都超过 ticket_threshold
333        //
334        // 两级告警互相独立,避免短期故障误触发工单系统。
335        let page_alerting = page_short_burn > self.config.burn_rate_threshold
336            && page_long_burn > self.config.burn_rate_threshold;
337        let ticket_alerting = ticket_short_burn > self.config.ticket_burn_rate_threshold
338            && ticket_long_burn > self.config.ticket_burn_rate_threshold;
339
340        SloBurnRate {
341            short_success_rate: page_short_rate,
342            long_success_rate: page_long_rate,
343            short_burn_rate: page_short_burn,
344            long_burn_rate: page_long_burn,
345            error_budget_remaining,
346            // 兼容旧字段:alerting 等价于 page_alerting
347            alerting: page_alerting,
348            ticket_short_success_rate: ticket_short_rate,
349            ticket_long_success_rate: ticket_long_rate,
350            ticket_short_burn_rate: ticket_short_burn,
351            ticket_long_burn_rate: ticket_long_burn,
352            page_alerting,
353            ticket_alerting,
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use std::thread::sleep;
362
363    #[test]
364    fn test_slo_no_data() {
365        let monitor = SloMonitor::new(SloConfig::default());
366        let rate = monitor.burn_rate();
367        assert_eq!(rate.short_success_rate, 1.0);
368        assert_eq!(rate.long_success_rate, 1.0);
369        assert!(!rate.alerting);
370        assert!(!rate.page_alerting);
371        assert!(!rate.ticket_alerting);
372    }
373
374    #[test]
375    fn test_slo_all_success() {
376        let monitor = SloMonitor::new(SloConfig::default());
377        for _ in 0..1000 {
378            monitor.record_success();
379        }
380        let rate = monitor.burn_rate();
381        assert_eq!(rate.short_success_rate, 1.0);
382        assert!(!rate.alerting);
383        assert!(!rate.page_alerting);
384        assert!(!rate.ticket_alerting);
385    }
386
387    #[test]
388    fn test_slo_with_failures() {
389        let monitor = SloMonitor::new(SloConfig {
390            target_success_rate: 0.99,
391            short_window: Duration::from_millis(100),
392            long_window: Duration::from_millis(200),
393            burn_rate_threshold: 1.0,
394            ticket_short_window: Duration::from_millis(300),
395            ticket_long_window: Duration::from_millis(500),
396            ticket_burn_rate_threshold: 1.0,
397        });
398        // 1000 次成功 + 100 次失败 = 90.9% 成功率(低于 99% SLO)
399        for _ in 0..1000 {
400            monitor.record_success();
401        }
402        for _ in 0..100 {
403            monitor.record_failure();
404        }
405        let rate = monitor.burn_rate();
406        assert!(rate.short_success_rate < 0.99);
407        assert!(rate.short_burn_rate > 1.0);
408        assert!(rate.error_budget_remaining < 1.0);
409        // Ticket 窗口也应看到失败
410        assert!(rate.ticket_short_burn_rate > 1.0);
411    }
412
413    #[test]
414    fn test_slo_page_alerting() {
415        let monitor = SloMonitor::new(SloConfig {
416            target_success_rate: 0.999,
417            short_window: Duration::from_millis(100),
418            long_window: Duration::from_millis(200),
419            burn_rate_threshold: 1.0,
420            ticket_short_window: Duration::from_millis(300),
421            ticket_long_window: Duration::from_millis(500),
422            ticket_burn_rate_threshold: 100.0, // 设置高阈值,确保 ticket 不触发
423        });
424        // 100 次成功 + 10 次失败 = 90.9% 成功率(远低于 99.9% SLO)
425        // 燃烧率 = (0.091 / 0.001) = 91x > 1.0 阈值
426        for _ in 0..100 {
427            monitor.record_success();
428        }
429        for _ in 0..10 {
430            monitor.record_failure();
431        }
432        let rate = monitor.burn_rate();
433        assert!(rate.page_alerting, "Page should be alerting: {:?}", rate);
434        // 兼容字段
435        assert!(rate.alerting);
436        // Ticket 不应触发(阈值很高)
437        assert!(
438            !rate.ticket_alerting,
439            "Ticket should NOT alerting: {:?}",
440            rate
441        );
442    }
443
444    #[test]
445    fn test_slo_ticket_alerting_independent() {
446        // 验证 Ticket 告警独立于 Page 告警
447        let monitor = SloMonitor::new(SloConfig {
448            target_success_rate: 0.999,
449            // Page 窗口很短,容易过期 → Page 不告警
450            short_window: Duration::from_millis(50),
451            long_window: Duration::from_millis(80),
452            burn_rate_threshold: 1.0,
453            // Ticket 窗口较长 → Ticket 告警
454            ticket_short_window: Duration::from_millis(500),
455            ticket_long_window: Duration::from_millis(1000),
456            ticket_burn_rate_threshold: 1.0,
457        });
458        for _ in 0..100 {
459            monitor.record_success();
460        }
461        for _ in 0..10 {
462            monitor.record_failure();
463        }
464        let rate = monitor.burn_rate();
465        // 此时 Page 和 Ticket 都应告警(数据在两个窗口内)
466        assert!(
467            rate.ticket_alerting,
468            "Ticket should be alerting: {:?}",
469            rate
470        );
471
472        // 等待 Page 窗口过期但 Ticket 窗口未过期
473        sleep(Duration::from_millis(200));
474
475        let rate2 = monitor.burn_rate();
476        // Page 窗口已过期 → Page 不告警
477        assert!(
478            !rate2.page_alerting,
479            "Page should NOT alerting after window rotation: {:?}",
480            rate2
481        );
482        // Ticket 窗口未过期 → Ticket 仍告警
483        assert!(
484            rate2.ticket_alerting,
485            "Ticket should still alerting: {:?}",
486            rate2
487        );
488    }
489
490    #[test]
491    fn test_window_rotation() {
492        let monitor = SloMonitor::new(SloConfig {
493            target_success_rate: 0.99,
494            short_window: Duration::from_millis(50),
495            long_window: Duration::from_millis(100),
496            burn_rate_threshold: 1.0,
497            ticket_short_window: Duration::from_millis(150),
498            ticket_long_window: Duration::from_millis(200),
499            ticket_burn_rate_threshold: 1.0,
500        });
501        // 第一批请求
502        for _ in 0..10 {
503            monitor.record_failure();
504        }
505        let rate1 = monitor.burn_rate();
506        assert!(rate1.short_burn_rate > 0.0);
507
508        // 等待所有窗口过期
509        sleep(Duration::from_millis(250));
510
511        // 所有窗口应已轮转
512        let rate2 = monitor.burn_rate();
513        assert_eq!(rate2.short_success_rate, 1.0); // 无数据视为 1.0
514        assert_eq!(rate2.long_success_rate, 1.0);
515        assert_eq!(rate2.ticket_short_success_rate, 1.0);
516        assert_eq!(rate2.ticket_long_success_rate, 1.0);
517    }
518
519    #[test]
520    fn test_default_config_is_google_sre_recommended() {
521        // v0.2.2 修复 P2-1:默认配置应符合 Google SRE Workbook 第 5 章推荐
522        let config = SloConfig::default();
523        // Page: 5m + 1h,14.4x
524        assert_eq!(config.short_window, Duration::from_secs(300)); // 5 分钟
525        assert_eq!(config.long_window, Duration::from_secs(3600)); // 1 小时
526        assert!((config.burn_rate_threshold - 14.4).abs() < 0.01);
527        // Ticket: 30m + 6h,6.0x
528        assert_eq!(config.ticket_short_window, Duration::from_secs(30 * 60)); // 30 分钟
529        assert_eq!(config.ticket_long_window, Duration::from_secs(6 * 3600)); // 6 小时
530        assert!((config.ticket_burn_rate_threshold - 6.0).abs() < 0.01);
531    }
532
533    #[test]
534    fn test_display_includes_both_alerts() {
535        let monitor = SloMonitor::new(SloConfig::default());
536        let rate = monitor.burn_rate();
537        let s = format!("{}", rate);
538        // Display 输出应包含 page 和 ticket 两级告警信息
539        assert!(s.contains("page["), "display should include page: {}", s);
540        assert!(
541            s.contains("ticket["),
542            "display should include ticket: {}",
543            s
544        );
545    }
546}