事件去重的核心規則是用領域語意判斷「哪兩筆事件代表同一件事」。原始 payload、request ID、收到時間和重試次數常常每次都不同,直接拿來比對會讓去重失效。

本章目標

學完本章後,你將能夠:

  1. 分辨 event ID 去重與 domain key 去重的差異
  2. 用 subject、event type、source group 與時間窗口設計 DedupKey
  3. 避免把不穩定欄位放進去重鍵
  4. 設計去重表的過期與清理策略
  5. 用 table-driven test 驗證去重邊界

【觀察】重複事件不一定長得一樣

重複事件的核心困難是外觀可能不同。HTTP callback 可能每次都有新的 request ID,queue message 可能因 retry 改變 delivery tag,timer 可能在下一輪掃描再次產生類似事件。

兩筆外部輸入可能長這樣:

1{
2  "request_id": "req_1001",
3  "event_id": "provider_7788",
4  "account_id": "acct_1",
5  "event_name": "activated",
6  "timestamp": "2026-04-22T10:00:03Z"
7}
1{
2  "request_id": "req_1002",
3  "event_id": "provider_7788_retry",
4  "account_id": "acct_1",
5  "event_name": "activated",
6  "timestamp": "2026-04-22T10:00:05Z"
7}

如果直接比對整包 JSON,這兩筆不同;如果從 domain 看,它們可能都是「同一個 account 在同一小段時間內變成 active」。

【判讀】去重鍵是語意決策

去重鍵的核心責任是把「相同事件」的定義寫進型別。它不是單純把 payload 做 hash;hash 只能回答 bytes 是否相同,不能回答領域事件是否相同。

1type DedupKey struct {
2    SubjectID string
3    Type      EventType
4    SourceSet string
5    Window    int64
6}

這個 key 表示:同一個 subject、同一種 event type、同一組來源語意、落在同一個時間窗口的事件,視為同一件事。

SourceSet 不一定等於原始來源名稱。多個來源若只是同一件事的不同傳輸管道,可以映射到同一個 source set;若兩個來源代表不同權威資料,則應分開。

【策略】先選擇去重層級

去重層級的核心選擇是 event ID、domain key 或兩者並用。不同層級解決的問題不同。

去重方式判斷依據適用情境風險
event ID外部或內部 event ID 相同上游提供穩定唯一 ID上游 retry 可能換 ID
domain keysubject、type、時間窗口相同多來源可能描述同一件事key 設太粗會誤殺事件
兩者並用event ID 先判斷,再用 domain key 補強上游 ID 大多可信但不完全穩定實作與測試較複雜

小型服務可以先使用 domain key。若上游提供可靠 event ID,則 event ID 可以成為第一層快速去重,domain key 作為跨來源重複的保護。

【執行】用內部事件建立 DedupKey

DedupKey 應該建立在 DomainEvent 上,而不是 raw input 上。這能讓 HTTP、queue、timer 進來的同類事件共用去重規則。

 1func NewDedupKey(event DomainEvent, window time.Duration) DedupKey {
 2    return DedupKey{
 3        SubjectID: event.SubjectID,
 4        Type:      event.Type,
 5        SourceSet: sourceSet(event.Source),
 6        Window:    event.OccurredAt.UnixNano() / int64(window),
 7    }
 8}
 9
10func sourceSet(source EventSource) string {
11    switch source {
12    case SourceHTTPCallback, SourceQueue:
13        return "external_delivery"
14    case SourceTimer:
15        return "internal_scan"
16    default:
17        return string(source)
18    }
19}

OccurredAt 通常比 ReceivedAt 更適合事件語意去重。兩筆 retry 可能收到時間不同,但實際描述的發生時間相近;若使用收到時間,系統忙碌或網路延遲就會改變去重結果。

【判讀】哪些欄位不該放進 key

去重鍵的核心限制是不能包含每次都會變的欄位。這類欄位適合用於追蹤、除錯或觀測,不適合用於判斷是否同一事件。

不適合放進 key 的欄位:

  • request_id:每次 request 都可能不同。
  • received_at:取決於系統接收時間,不一定是事件語意。
  • delivery_attempt:重試次數本身就是重複事件的證據。
  • raw payload hash:欄位順序、metadata 或非語意欄位可能改變。
  • client IP、瀏覽器識別字串:代表傳輸脈絡,不代表事件本身。

適合放進 key 的欄位:

  • subject ID:事件作用的對象。
  • event type:發生了什麼事。
  • source set:資料權威或來源語意。
  • occurred time window:同一事件可接受的時間範圍。

【策略】時間窗口是取捨

時間窗口的核心作用是容忍短時間內的重送。窗口越短,越不容易誤殺不同事件;窗口越長,越能吸收延遲與 retry。

1const defaultDedupWindow = 30 * time.Second

窗口大小應該依事件語意決定:

事件類型可用窗口理由
account activated1-5 分鐘同一 account 短時間重複啟用通常是 retry
notification created不一定適合時間窗口使用者可能短時間建立多筆通知
job finished30 秒-2 分鐘job 完成事件通常只應發生一次
heartbeat received不應去重成單一事件heartbeat 本身就是週期訊號

時間窗口不是萬用答案。若事件本身允許短時間內多次發生,就需要更細的 subject 或 event ID,而不是把窗口調小到碰運氣。

【執行】Deduper 要保護共享 map

in-memory deduper 的核心責任是記住近期看過的 key,並在多 goroutine 下保持安全。只要 processor 可能同時處理事件,就需要 mutex 或單一 goroutine 擁有去重表。

 1type Deduper struct {
 2    mu      sync.Mutex
 3    seen    map[DedupKey]time.Time
 4    window  time.Duration
 5    expires time.Duration
 6}
 7
 8func NewDeduper(window, expires time.Duration) *Deduper {
 9    return &Deduper{
10        seen:    make(map[DedupKey]time.Time),
11        window:  window,
12        expires: expires,
13    }
14}
15
16func (d *Deduper) Seen(ctx context.Context, event DomainEvent) (bool, error) {
17    d.mu.Lock()
18    defer d.mu.Unlock()
19
20    key := NewDedupKey(event, d.window)
21    if _, ok := d.seen[key]; ok {
22        return true, nil
23    }
24
25    d.seen[key] = event.ReceivedAt
26    return false, nil
27}

ctx 在 memory 實作中可能用不到,但保留在 port 上能讓未來改成 Redis、資料庫或遠端服務時支援取消與逾時。

【執行】去重表必須清理

去重表的核心風險是無限制成長。只要把 key 放進 map,就必須定義 key 何時過期。

 1func (d *Deduper) Cleanup(now time.Time) {
 2    d.mu.Lock()
 3    defer d.mu.Unlock()
 4
 5    for key, seenAt := range d.seen {
 6        if now.Sub(seenAt) > d.expires {
 7            delete(d.seen, key)
 8        }
 9    }
10}

expires 通常應該大於 window。窗口決定兩筆事件是否可能被視為相同,過期時間決定 key 在記憶體中保留多久;兩者不是同一個概念。

【測試】用 table-driven test 固定語意

去重測試的核心目標是把「什麼算相同」寫成案例。這比只測 map 是否有資料更重要。

 1func TestDedupKey(t *testing.T) {
 2    base := time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC)
 3
 4    tests := []struct {
 5        name string
 6        a    DomainEvent
 7        b    DomainEvent
 8        same bool
 9    }{
10        {
11            name: "same subject type and window",
12            a: DomainEvent{SubjectID: "acct_1", Type: EventAccountActivated, Source: SourceHTTPCallback, OccurredAt: base},
13            b: DomainEvent{SubjectID: "acct_1", Type: EventAccountActivated, Source: SourceQueue, OccurredAt: base.Add(5 * time.Second)},
14            same: true,
15        },
16        {
17            name: "different subject",
18            a: DomainEvent{SubjectID: "acct_1", Type: EventAccountActivated, Source: SourceHTTPCallback, OccurredAt: base},
19            b: DomainEvent{SubjectID: "acct_2", Type: EventAccountActivated, Source: SourceHTTPCallback, OccurredAt: base},
20            same: false,
21        },
22        {
23            name: "outside window",
24            a: DomainEvent{SubjectID: "acct_1", Type: EventAccountActivated, Source: SourceHTTPCallback, OccurredAt: base},
25            b: DomainEvent{SubjectID: "acct_1", Type: EventAccountActivated, Source: SourceHTTPCallback, OccurredAt: base.Add(2 * time.Minute)},
26            same: false,
27        },
28    }
29
30    for _, tt := range tests {
31        t.Run(tt.name, func(t *testing.T) {
32            got := NewDedupKey(tt.a, time.Minute) == NewDedupKey(tt.b, time.Minute)
33            if got != tt.same {
34                t.Fatalf("same key = %v, want %v", got, tt.same)
35            }
36        })
37    }
38}

這個測試把來源融合、subject 差異與時間窗口都明確化。未來調整 key 時,測試會提醒你正在改變事件語意,而不只是改一個 struct。

本章不處理

本章先處理單一服務內的事件去重語意;跨節點一致性與 idempotency store,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 event normalization、processor 與 source priority;如果你要先回看語言教材,可以讀:

小結

事件去重是領域語意設計,不是 payload 比對。好的 DedupKey 會使用 subject、event type、source set 與合適的 occurred time window,並避免 request ID、收到時間與 raw payload hash 這類不穩定欄位。去重表還必須有清理策略,否則事件系統會用記憶體 leak 換取短期正確性。