标签:Kafka Golang 定时任务 事件驱动 架构设计
Go Kafka 库:github.com/segmentio/kafka-go
为什么要替换定时任务?
定时任务在小规模系统中运行良好,但随着业务增长,问题逐渐暴露:
| 问题 | 描述 |
|---|
| 时间耦合 | 必须等到固定时间点才触发,实时性差 |
| 重复执行 | 多实例部署需要额外分布式锁,稍有不慎就重复执行 |
| 全量扫描 | 通常需要扫全表找待处理数据,数据量大时拖垮数据库 |
| 失败难追踪 | 没有自然的重试机制,失败了难以回溯 |
| 扩展性差 | 任务量增大时无法动态扩容消费能力 |
方案一:事件驱动替代状态轮询
适用场景
- 订单支付后触发发货
- 用户注册后发送欢迎邮件
- 状态流转触发下游处理
流程对比
❌ 旧方案(定时轮询)
┌─────────────────────────────────────────────┐ │ Cron Job(每 5 分钟) │ │ ↓ │ │ SELECT * FROM orders WHERE status='paid' │ ← 全表扫描,数据量大时极慢 │ ↓ │ │ for each order → 触发发货逻辑 │ │ ↓ │ │ UPDATE orders SET status='shipping' │ └─────────────────────────────────────────────┘ 问题:最多延迟 5 分钟响应,高峰期扫表慢
✅ 新方案(事件驱动)
支付服务 Kafka 发货服务 │ │ │ │ 支付成功 │ │ │──发布 order.paid ────→│ │ │ │──推送消息 ────────────→│ │ │ │ 毫秒级实时触发发货 │ │ │ 处理完提交 offset │ │ │
|
Go 实现
package payment
import ( "context" "encoding/json" "time"
"github.com/segmentio/kafka-go" )
type OrderPaidEvent struct { OrderID string `json:"order_id"` UserID string `json:"user_id"` Amount float64 `json:"amount"` PaidAt time.Time `json:"paid_at"` MessageID string `json:"message_id"` }
var writer = &kafka.Writer{ Addr: kafka.TCP("kafka1:9092", "kafka2:9092", "kafka3:9092"),
Topic: "order.order.paid",
Balancer: &kafka.Hash{},
RequiredAcks: kafka.RequireAll,
Async: false, }
func OnPaymentSuccess(ctx context.Context, orderID, userID string, amount float64) error { event := OrderPaidEvent{ OrderID: orderID, UserID: userID, Amount: amount, PaidAt: time.Now(), MessageID: orderID + "_paid", } payload, _ := json.Marshal(event)
return writer.WriteMessages(ctx, kafka.Message{ Key: []byte(orderID), Value: payload, }) }
|
package shipping
import ( "context" "encoding/json" "log" "time"
"github.com/segmentio/kafka-go" )
func StartConsumer(ctx context.Context) { r := kafka.NewReader(kafka.ReaderConfig{ Brokers: []string{"kafka1:9092", "kafka2:9092"},
Topic: "order.order.paid",
GroupID: "shipping-service-group",
MinBytes: 1,
MaxBytes: 10e6,
CommitInterval: 0,
StartOffset: kafka.FirstOffset,
MaxWait: 3 * time.Second, }) defer r.Close()
for { msg, err := r.FetchMessage(ctx) if err != nil { log.Printf("拉取消息失败: %v", err) continue }
var event OrderPaidEvent if err := json.Unmarshal(msg.Value, &event); err != nil { log.Printf("消息解析失败,跳过: %v", err) r.CommitMessages(ctx, msg) continue }
if err := processWithIdempotent(ctx, event); err != nil { log.Printf("处理失败,发往 DLQ: %v", err) sendToDLQ(ctx, msg) }
if err := r.CommitMessages(ctx, msg); err != nil { log.Printf("offset 提交失败: %v", err) } } }
func processWithIdempotent(ctx context.Context, event OrderPaidEvent) error { ok, _ := rdb.SetNX(ctx, "msg:processed:"+event.MessageID, 1, 24*time.Hour, ).Result()
if !ok { log.Printf("重复消息,跳过: %s", event.MessageID) return nil } return shippingService.Trigger(event.OrderID) }
|
方案二:延迟消息替代延时定时任务
适用场景
- 下单 30 分钟未支付自动取消
- 发货 7 天后自动确认收货
- 优惠券到期提醒
时间轮原理
时间轮(Timing Wheel) 是一种高效处理大量定时任务的数据结构。
想象一个时钟表盘,表盘被均匀分成 N 个槽(Slot),每个槽对应一个时间刻度(如 1 秒)。
指针每隔一个刻度向前走一格,走到哪个槽就触发该槽中所有到期的任务。
0s ┌──┐ 59s──┤ ├──1s ← 指针当前在 0s,每秒走一格 │ │ ... │ │ ... 每个槽里挂着"在这个时刻到期"的任务列表 │ │ 30s──┴──┘ 到期的任务从槽中取出并执行
优点:插入任务 O(1),触发任务 O(1),远优于最小堆的 O(log n) 适合:海量定时任务(如百万级订单超时取消)
|
对比最小堆(Go time.AfterFunc 内部实现):
- 最小堆:任务量大时,每次插入/删除都是 O(log n),任务多了性能下降
- 时间轮:无论多少任务,插入和触发都是 O(1),更适合高并发延迟场景
流程图
┌─────────────────────────────────────────────┐ │ 延迟调度服务 │ │ │ 业务服务 │ delay-queue Topic │ 真实 Topic │ │ │ │ │ │ 下单成功 │ │ │ │ │─写入 ────────→│────────→ consume 拉取消息 │ │ │ execute_at │ │ │ │ │ = now+30min │ ↓ │ │ │ │ 时间轮(每秒 tick) │ │ │ │ 槽内挂载到期任务 │ │ │ │ │ 30分钟后到期 │ │ │ │ └──────────────────────────────→│───────→ 取消订单 │ │ 转发到 order.cancel Topic │ │ Consumer │ │ │ │ └───────────────┴──────────────────────────────────────────────┴───────┘
|
Go 实现
package delay
import ( "context" "encoding/json" "time"
"github.com/segmentio/kafka-go" )
type DelayMessage struct { RealTopic string `json:"real_topic"` Key string `json:"key"` Value json.RawMessage `json:"value"` ExecuteAt int64 `json:"execute_at"` }
var writer = &kafka.Writer{ Addr: kafka.TCP("kafka1:9092"), Topic: "delay-queue", RequiredAcks: kafka.RequireAll, }
func SendDelay(ctx context.Context, realTopic, key string, value any, delay time.Duration) error { payload, _ := json.Marshal(value) msg := DelayMessage{ RealTopic: realTopic, Key: key, Value: payload, ExecuteAt: time.Now().Add(delay).UnixMilli(), } body, _ := json.Marshal(msg)
return writer.WriteMessages(ctx, kafka.Message{ Key: []byte(key), Value: body, }) }
func OnOrderCreated(ctx context.Context, orderID string) { SendDelay(ctx, "order.order.cancel", orderID, map[string]string{"order_id": orderID}, 30*time.Minute, ) }
|
package delay
import ( "context" "encoding/json" "log" "time"
"github.com/segmentio/kafka-go" )
type timerTask struct { msg DelayMessage commitMsg kafka.Message }
type Scheduler struct { reader *kafka.Reader writers map[string]*kafka.Writer timerCh chan timerTask }
func NewScheduler() *Scheduler { return &Scheduler{ reader: kafka.NewReader(kafka.ReaderConfig{ Brokers: []string{"kafka1:9092"}, Topic: "delay-queue", GroupID: "delay-scheduler-group", CommitInterval: 0, MinBytes: 1, MaxBytes: 10e6, }), writers: make(map[string]*kafka.Writer), timerCh: make(chan timerTask, 10000), } }
func (s *Scheduler) Start(ctx context.Context) { go s.consume(ctx) go s.dispatch(ctx) }
func (s *Scheduler) consume(ctx context.Context) { for { m, err := s.reader.FetchMessage(ctx) if err != nil { log.Printf("delay-queue 拉取失败: %v", err) continue }
var dm DelayMessage if err := json.Unmarshal(m.Value, &dm); err != nil { log.Printf("延迟消息解析失败,跳过: %v", err) s.reader.CommitMessages(ctx, m) continue }
wait := time.Until(time.UnixMilli(dm.ExecuteAt)) if wait < 0 { wait = 0 }
go func(task timerTask, wait time.Duration) { log.Printf("延迟任务入轮: key=%s, 等待=%v", task.msg.Key, wait)
select { case <-time.After(wait): s.timerCh <- task case <-ctx.Done(): log.Printf("调度器关闭,任务丢弃: key=%s", task.msg.Key) } }(timerTask{msg: dm, commitMsg: m}, wait) } }
func (s *Scheduler) dispatch(ctx context.Context) { for { select { case task := <-s.timerCh: w := s.getWriter(task.msg.RealTopic) err := w.WriteMessages(ctx, kafka.Message{ Key: []byte(task.msg.Key), Value: task.msg.Value, }) if err != nil { log.Printf("转发到 %s 失败: %v,消息将重新投递", task.msg.RealTopic, err) continue } if err := s.reader.CommitMessages(ctx, task.commitMsg); err != nil { log.Printf("offset 提交失败: %v", err) } log.Printf("延迟消息转发成功: key=%s → topic=%s", task.msg.Key, task.msg.RealTopic)
case <-ctx.Done(): return } } }
func (s *Scheduler) getWriter(topic string) *kafka.Writer { if w, ok := s.writers[topic]; ok { return w } w := &kafka.Writer{ Addr: kafka.TCP("kafka1:9092"), Topic: topic, RequiredAcks: kafka.RequireAll, } s.writers[topic] = w return w }
|
方案三:流式窗口替代定时批处理
适用场景
- 每小时统计订单金额报表
- 每天对账汇总
- 实时监控指标聚合
流程图
❌ 旧方案(定时批处理)
Cron 每小时触发 ↓ SELECT SUM(amount) FROM orders ← 重复全量计算,数据库压力大 WHERE created_at BETWEEN ? AND ? ↓ 写入报表表
✅ 新方案(滚动窗口聚合)
订单服务 Kafka 聚合消费者 Doris │ │ │ │ │ 每笔订单 ──────→│ │ │ │ │── 实时推送 ────────→│ │ │ │ │ 内存累加 │ │ │ │ 整点窗口关闭 │ │ │ │──── 批量写入 ────────→│ │ │ │ │ 报表 / BI 查询
|
Go 实现
package aggregator
import ( "context" "encoding/json" "log" "sync" "time"
"github.com/segmentio/kafka-go" )
type OrderEvent struct { OrderID string `json:"order_id"` MerchantID string `json:"merchant_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` }
type WindowResult struct { MerchantID string WindowStart time.Time WindowEnd time.Time TotalAmount float64 Count int64 }
type WindowAggregator struct { mu sync.Mutex windows map[string]*WindowResult ticker *time.Ticker }
func NewWindowAggregator() *WindowAggregator { wa := &WindowAggregator{ windows: make(map[string]*WindowResult), ticker: time.NewTicker(time.Hour), } go wa.flushLoop() return wa }
func (wa *WindowAggregator) Start(ctx context.Context) { r := kafka.NewReader(kafka.ReaderConfig{ Brokers: []string{"kafka1:9092"}, Topic: "order.order.created", GroupID: "hourly-aggregator-group", MinBytes: 1e3, MaxBytes: 10e6, MaxWait: 500 * time.Millisecond, }) defer r.Close()
for { m, err := r.FetchMessage(ctx) if err != nil { continue }
var event OrderEvent if err := json.Unmarshal(m.Value, &event); err != nil { r.CommitMessages(ctx, m) continue }
wa.accumulate(event) r.CommitMessages(ctx, m) } }
func (wa *WindowAggregator) accumulate(event OrderEvent) { windowStart := event.CreatedAt.Truncate(time.Hour) key := event.MerchantID + "|" + windowStart.String()
wa.mu.Lock() defer wa.mu.Unlock()
if _, ok := wa.windows[key]; !ok { wa.windows[key] = &WindowResult{ MerchantID: event.MerchantID, WindowStart: windowStart, WindowEnd: windowStart.Add(time.Hour), } } wa.windows[key].TotalAmount += event.Amount wa.windows[key].Count++ }
func (wa *WindowAggregator) flushLoop() { for range wa.ticker.C { wa.mu.Lock() toFlush := wa.windows wa.windows = make(map[string]*WindowResult) wa.mu.Unlock()
for _, result := range toFlush { if err := doris.Insert(result); err != nil { log.Printf("写入 Doris 失败: %v", err) } } log.Printf("窗口刷新完成,写入 %d 条聚合结果", len(toFlush)) } }
|
方案四:心跳超时检测替代健康检查定时任务
适用场景
- IoT 设备在线检测
- 微服务实例存活检测
- 长连接客户端超时判断
流程图
设备 / 服务 Kafka 心跳消费者 Redis │ │ │ │ │ 每 30s 发心跳 │ │ │ │──→ device.heartbeat ────→│ │ │ │ │──── 实时推送 ───────────→│ │ │ │ │ SET device:online │ │ │ │ :deviceID EX 90 ──→│ TTL 刷新 │ │ │ │ │ (超过 90s 无心跳) │ │ │ │ │ │ Key 过期 ──→ 触发过期事件 │ │ │ │ │ │ │ 告警服务 ←─ 订阅过期事件 │ │ │ 钉钉 / PagerDuty 通知
|
Go 实现
package device
import ( "context" "encoding/json" "time"
"github.com/segmentio/kafka-go" )
type HeartbeatEvent struct { DeviceID string `json:"device_id"` IP string `json:"ip"` Timestamp time.Time `json:"timestamp"` }
var writer = &kafka.Writer{ Addr: kafka.TCP("kafka1:9092"), Topic: "device.heartbeat", Balancer: &kafka.Hash{}, RequiredAcks: kafka.RequireOne, Async: true, }
func StartHeartbeat(ctx context.Context, deviceID, ip string) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop()
for { select { case <-ctx.Done(): return case <-ticker.C: event := HeartbeatEvent{ DeviceID: deviceID, IP: ip, Timestamp: time.Now(), } payload, _ := json.Marshal(event) writer.WriteMessages(ctx, kafka.Message{ Key: []byte(deviceID), Value: payload, }) } } }
|
package monitor
import ( "context" "encoding/json" "log" "time"
"github.com/redis/go-redis/v9" "github.com/segmentio/kafka-go" )
const ( HeartbeatTTL = 90 * time.Second )
func StartHeartbeatConsumer(ctx context.Context, rdb *redis.Client) { r := kafka.NewReader(kafka.ReaderConfig{ Brokers: []string{"kafka1:9092"}, Topic: "device.heartbeat", GroupID: "device-monitor-group", MinBytes: 1, MaxBytes: 10e6, MaxWait: 1 * time.Second, }) defer r.Close()
for { m, err := r.FetchMessage(ctx) if err != nil { continue }
var event HeartbeatEvent if err := json.Unmarshal(m.Value, &event); err != nil { r.CommitMessages(ctx, m) continue }
rdb.Set(ctx, "device:online:"+event.DeviceID, event.IP, HeartbeatTTL, )
r.CommitMessages(ctx, m) log.Printf("心跳更新: device=%s ip=%s ttl=%v", event.DeviceID, event.IP, HeartbeatTTL) } }
|
package monitor
import ( "context" "log" "strings"
"github.com/redis/go-redis/v9" )
func StartOfflineListener(ctx context.Context, rdb *redis.Client) { pubsub := rdb.PSubscribe(ctx, "__keyevent@0__:expired") defer pubsub.Close()
log.Println("开始监听设备离线事件...")
for msg := range pubsub.Channel() { key := msg.Payload
if !strings.HasPrefix(key, "device:online:") { continue }
deviceID := strings.TrimPrefix(key, "device:online:") log.Printf("⚠️ 设备离线: %s", deviceID)
go alertService.SendOfflineAlert(ctx, deviceID) } }
|
方案五:死信队列(DLQ)兜底
无论哪种方案,消费失败的消息都需要有去处,不能阻塞主流程。
流程图
主 Topic │ ↓ Consumer 处理 │ ├── 成功 ──→ CommitOffset,结束 │ └── 失败 │ ├── 可重试(网络抖动等)──→ 指数退避重试(1s → 2s → 4s,最多 3 次) │ │ │ └── 仍失败 ──→ 写入 DLQ Topic │ └── 不可重试(数据格式错误等)──────→ 写入 DLQ Topic │ ┌───────┴────────┐ │ DLQ Consumer │ │ 人工审查 / 修复 │ │ 修复后重新投递 │ └────────────────┘
|
Go 实现
package consumer
import ( "context" "fmt" "log" "time"
"github.com/segmentio/kafka-go" )
var dlqWriter = &kafka.Writer{ Addr: kafka.TCP("kafka1:9092"), RequiredAcks: kafka.RequireAll, }
type NonRetryableError struct{ msg string } func (e NonRetryableError) Error() string { return e.msg }
func processWithRetry(ctx context.Context, msg kafka.Message, handler func(kafka.Message) error) error { maxRetry := 3
for i := 0; i < maxRetry; i++ { err := handler(msg) if err == nil { return nil }
if _, ok := err.(NonRetryableError); ok { log.Printf("不可重试错误,直接发往 DLQ: %v", err) return sendToDLQ(ctx, msg, err) }
if i < maxRetry-1 { wait := time.Duration(1<<i) * time.Second log.Printf("第 %d 次重试,%v 后重试: %v", i+1, wait, err) select { case <-time.After(wait): case <-ctx.Done(): return ctx.Err() } } }
log.Printf("超出最大重试次数(%d),发往 DLQ", maxRetry) return sendToDLQ(ctx, msg, fmt.Errorf("超出最大重试次数")) }
func sendToDLQ(ctx context.Context, msg kafka.Message, reason error) error { dlqTopic := msg.Topic + ".DLQ"
headers := append(msg.Headers, kafka.Header{Key: "original-topic", Value: []byte(msg.Topic)}, kafka.Header{Key: "fail-reason", Value: []byte(reason.Error())}, kafka.Header{Key: "fail-time", Value: []byte(time.Now().Format(time.RFC3339))}, kafka.Header{Key: "original-partition", Value: []byte(fmt.Sprintf("%d", msg.Partition))}, kafka.Header{Key: "original-offset", Value: []byte(fmt.Sprintf("%d", msg.Offset))}, )
return dlqWriter.WriteMessages(ctx, kafka.Message{ Topic: dlqTopic, Key: msg.Key, Value: msg.Value, Headers: headers, }) }
|
方案对比总结
| 定时任务场景 | Kafka 替换方案 | 实时性 | 复杂度 |
|---|
| 状态变更触发下游 | 事件驱动(方案一) | 毫秒级 | 低 |
| N 分钟后执行某操作 | 延迟消息 + 时间轮(方案二) | 秒级 | 中 |
| 定时批量统计报表 | 滚动窗口聚合(方案三) | 分钟级 | 中 |
| 心跳 / 超时检测 | Kafka + Redis TTL(方案四) | 秒级 | 低 |
| 失败补偿 / 兜底 | 死信队列 DLQ(方案五) | 人工介入 | 低 |
不适合替换的场景
核心判断标准:
触发条件是”某件事发生了”→ 用 Kafka 事件驱动
触发条件是”到了某个时间点”→ Cron 定时任务仍是最简选择,不要过度设计