应使用 chan 而非 time.Ticker 实现调度,因后者仅支持固定间隔触发,无法满足微服务中动态调周期、暂停/恢复、条件跳过等需求;chan 可携带上下文、支持判断/丢弃/延迟转发,配合 select 和 time.After 实现灵活可控的调度。

为什么不用 time.Ticker 而要用 chan 做调度?
因为 time.Ticker 只能固定间隔触发,而真实微服务里常要动态调整周期、暂停/恢复、或按条件跳过某次执行。用 chan 手动控制信号流,才能把调度权真正交到业务逻辑手里。
典型场景:订单超时关单(不同订单超时时间不同)、库存预占自动释放(依赖外部状态)、灰度任务分批下发(需配合配置中心)。
-
time.Ticker发出的信号无法携带上下文,每次都是“干一次”,没法决定“干不干”或“干哪个” - 用
chan struct{}或chan *Task当信号通道,接收方可以做判断、合并、丢弃、延迟转发 - 注意别漏掉
select的default分支——否则阻塞在 channel 上会卡死整个 goroutine
select 配合 time.After 实现带超时的调度信号生成
不要直接在 for 循环里调 time.Sleep,那会阻塞 goroutine;也不要用 time.AfterFunc,它只执行一次且无法取消。正确做法是每次 select 时动态生成下一个触发点。
// 每 5~10 秒随机触发一次,带上下文取消支持
func runScheduler(ctx context.Context, taskCh chan<- *Task) {
ticker := time.NewTimer(0)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
task := &Task{ID: uuid.New().String()}
select {
case taskCh <- task:
default: // 队列满就丢弃,避免阻塞调度器
}
// 下次触发时间:5~10 秒后
ticker.Reset(time.Duration(5+rand.Intn(5)) * time.Second)
}
}
}-
ticker.Reset()比新建time.After更省内存,避免 timer 泄漏 - 往
taskCh发送时加select+default,防止消费者慢导致调度 goroutine 卡住 - 别在
case 里做耗时操作——调度逻辑必须轻量,重活交给 worker goroutine 处理
如何安全关闭 channel 并通知所有消费者?
Go 中 channel 不能重复 close,也不能判断是否已 close,所以不能靠关 channel 来“广播停止”。正确方式是用 context.Context 控制生命周期,channel 只负责传递信号。
立即学习“go语言免费学习笔记(深入)”;
- 永远不要
close(taskCh)—— 消费者读到零值会 panic,且无法区分“关闭”和“空任务” - 消费者应监听
ctx.Done(),并在退出前处理完已接收的任务(比如等正在执行的task.Run()结束) - 如果必须通知“最后一批任务”,改用额外的
doneCh chan struct{},由调度器在ctx取消后 close 它,消费者 select 等待它再退出
多个调度器共用一个 channel 时的常见竞争问题
一个 chan *Task 被多个 goroutine 写入本身没问题,但若它们共享同一套触发逻辑(比如都基于同一个配置变更),就容易重复发相同任务。
- 典型错误:两个定时 goroutine 同时读到配置更新,各自发一遍关单任务 → 重复关单
- 解决方案不是加锁,而是用 “单点调度器 + 多 worker” 架构:只有一个 goroutine 负责读配置、生成任务、写入
taskCh;其他 goroutine 只负责从taskCh消费 - 如果真需要多生产者,给每个任务加唯一
task.ID,消费者入库前先查幂等表,而不是靠 channel 去重
channel 本身不解决业务语义问题,它只是信号管道。任务去重、顺序保证、失败重试,都得在业务层补全,别指望 channel 自动帮你扛。


















