← 中文资源

简体中文版本

时钟偏移

时钟偏移是不同机器时钟之间的差异;它会破坏时间戳排序、租约安全和最后写入者获胜的冲突解决。

阅读英文原文

工作原理

每台机器都有随温度和老化漂移的石英振荡器。NTP 或 PTP 定期估计偏移,并平滑或步进调整时钟。虚拟机会暂停,恢复后时钟可能向前跳。即使管理良好的集群也会有毫秒级偏移,配置错误的主机可能偏差数秒甚至数分钟。

墙上时钟回答“这里现在几点”,而不是“跨机器哪个事件先发生”。单调时钟不受跳变影响,适合测量单机时长,但其纪元任意,不能跨主机比较。逻辑时钟,例如 Lamport 时间戳和向量时钟,不依赖物理时间即可捕获因果关系。

危害

最后写入者获胜的冲突解决使用时间戳,偏移可能丢弃更新的写入。基于租约的锁假设持有者的时钟会在另一节点接管前到期,偏移可能让两个持有者同时有效。JWT 校验、缓存 TTL 和限流窗口也依赖时间。多主机日志合并后可能顺序错乱,增加事故分析难度。

缓解

保持 NTP 健康并监控偏移。不要把墙上时钟用于正确性排序;使用版本号、向量时钟或集中式定序器。对租约使用栅栏令牌,使过期持有者无法提交,并加入大于最大预期偏移的安全余量。需要严格界限的系统可以使用带不确定性区间的专用时钟,并在提交前等待不确定性过去。

权衡与示例

逻辑时钟捕获因果,但随参与者数量增长并需要裁剪。集中式时间戳简化排序,却增加依赖和延迟。有界不确定性时钟需要基础设施并引入等待。

示例:两个副本更新同一条记录。副本 A 的时钟快 300 毫秒,因此它较旧的写入携带更晚的时间戳,在最后写入者获胜策略下胜出,静默丢失了更新的值。版本向量或服务器分配的序列号可以保留两次更新,并暴露冲突而不是掩盖它。

代码示例

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;

#[derive(Clone)]
struct FakeClock {
    millis: Arc<AtomicU64>,
}

impl FakeClock {
    fn new() -> Self {
        Self {
            millis: Arc::new(AtomicU64::new(0)),
        }
    }

    fn now(&self) -> Duration {
        Duration::from_millis(self.millis.load(Ordering::SeqCst))
    }

    fn advance(&self, delta: Duration) {
        self.millis
            .fetch_add(delta.as_millis() as u64, Ordering::SeqCst);
    }
}

struct Token {
    expires_at: Duration,
}

impl Token {
    fn issue(clock: &FakeClock, ttl: Duration) -> Self {
        Self {
            expires_at: clock.now() + ttl,
        }
    }

    fn expired(&self, clock: &FakeClock) -> bool {
        clock.now() >= self.expires_at
    }
}

fn main() {
    let clock = FakeClock::new();
    let token = Token::issue(&clock, Duration::from_secs(60));
    clock.advance(Duration::from_secs(59));
    println!("expired at 59s: {}", token.expired(&clock));
    clock.advance(Duration::from_secs(1));
    println!("expired at 60s: {}", token.expired(&clock));
}
package main

import (
	"fmt"
	"sort"
	"sync"
	"time"
)

type SlidingWindowLimiter struct {
	mu     sync.Mutex
	limit  int
	window time.Duration
	now    func() time.Time
	events []time.Time
}

func NewSlidingWindowLimiter(limit int, window time.Duration, now func() time.Time) *SlidingWindowLimiter {
	if limit <= 0 || window <= 0 {
		panic("limit and window must be positive")
	}
	if now == nil {
		now = time.Now
	}
	return &SlidingWindowLimiter{limit: limit, window: window, now: now}
}

func (l *SlidingWindowLimiter) prune(current time.Time) []time.Time {
	cutoff := current.Add(-l.window)
	first := sort.Search(len(l.events), func(i int) bool {
		return l.events[i].After(cutoff)
	})
	l.events = append(l.events[:0], l.events[first:]...)
	return l.events
}

func (l *SlidingWindowLimiter) Allow() bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	current := l.now()
	active := l.prune(current)
	if len(active) >= l.limit {
		return false
	}
	l.events = append(active, current)
	return true
}

func (l *SlidingWindowLimiter) Remaining() int {
	l.mu.Lock()
	defer l.mu.Unlock()
	active := l.prune(l.now())
	if remaining := l.limit - len(active); remaining > 0 {
		return remaining
	}
	return 0
}

func main() {
	clock := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
	now := func() time.Time { return clock }
	limiter := NewSlidingWindowLimiter(3, time.Second, now)

	for i := 0; i < 4; i++ {
		clock = clock.Add(100 * time.Millisecond)
		fmt.Printf("t=%dms allow=%v remaining=%d\n", (i+1)*100, limiter.Allow(), limiter.Remaining())
	}

	clock = clock.Add(700 * time.Millisecond)
	fmt.Println("after window slides:", limiter.Allow())
}