设计通知推送系统
问题
如何用 Go 设计一个支持多渠道(站内信、邮件、短信、Push)的通知推送系统?
答案
核心架构
核心模型
// 通知类型
type Channel string
const (
ChannelInApp Channel = "in_app"
ChannelEmail Channel = "email"
ChannelSMS Channel = "sms"
ChannelPush Channel = "push"
)
// 通知请求
type NotifyRequest struct {
UserID string `json:"user_id"`
Event string `json:"event"` // 事件类型:order_paid, comment_reply
Channels []Channel `json:"channels"` // 推送渠道
Variables map[string]string `json:"variables"` // 模板变量
Priority int `json:"priority"` // 优先级 1(高)~5(低)
}
// 通知模板
type Template struct {
Event string `json:"event"`
Channel Channel `json:"channel"`
Title string `json:"title"` // 支持变量 {{.username}}
Body string `json:"body"`
}
模板引擎
import "text/template"
func RenderTemplate(tmpl Template, vars map[string]string) (string, string, error) {
titleTmpl, err := template.New("title").Parse(tmpl.Title)
if err != nil {
return "", "", err
}
bodyTmpl, err := template.New("body").Parse(tmpl.Body)
if err != nil {
return "", "", err
}
var titleBuf, bodyBuf strings.Builder
titleTmpl.Execute(&titleBuf, vars)
bodyTmpl.Execute(&bodyBuf, vars)
return titleBuf.String(), bodyBuf.String(), nil
}
调度器(策略模式)
// 发送器接口
type Sender interface {
Send(ctx context.Context, userID, title, body string) error
Channel() Channel
}
// 调度器:根据渠道分发
type Dispatcher struct {
senders map[Channel]Sender
tmplRepo TemplateRepository
}
func (d *Dispatcher) Dispatch(ctx context.Context, req NotifyRequest) error {
for _, ch := range req.Channels {
sender, ok := d.senders[ch]
if !ok {
log.Printf("未知渠道: %s", ch)
continue
}
// 获取模板并渲染
tmpl, err := d.tmplRepo.Get(req.Event, ch)
if err != nil {
return fmt.Errorf("模板不存在: %s/%s", req.Event, ch)
}
title, body, _ := RenderTemplate(tmpl, req.Variables)
// 异步发送
go func(s Sender, title, body string) {
if err := s.Send(ctx, req.UserID, title, body); err != nil {
log.Printf("发送失败 [%s] user=%s: %v", s.Channel(), req.UserID, err)
// 失败重试(推入重试队列)
}
}(sender, title, body)
}
return nil
}
各渠道实现
// 站内信:写入数据库
type InAppSender struct{ db *gorm.DB }
func (s *InAppSender) Send(ctx context.Context, userID, title, body string) error {
return s.db.Create(&InAppMessage{
UserID: userID,
Title: title,
Body: body,
IsRead: false,
Created: time.Now(),
}).Error
}
func (s *InAppSender) Channel() Channel { return ChannelInApp }
// 邮件
type EmailSender struct{ client *smtp.Client }
func (s *EmailSender) Send(ctx context.Context, userID, title, body string) error {
email := getUserEmail(userID)
return sendEmail(s.client, email, title, body)
}
func (s *EmailSender) Channel() Channel { return ChannelEmail }
// Push(FCM/APNs)
type PushSender struct{ fcmClient *fcm.Client }
func (s *PushSender) Send(ctx context.Context, userID, title, body string) error {
token := getDeviceToken(userID)
msg := &messaging.Message{
Token: token,
Notification: &messaging.Notification{Title: title, Body: body},
}
_, err := s.fcmClient.Send(ctx, msg)
return err
}
func (s *PushSender) Channel() Channel { return ChannelPush }
用户偏好 & 频率控制
// 用户可以关闭某些渠道的通知
func FilterChannels(userID string, channels []Channel) []Channel {
prefs := getUserPreferences(userID) // 从缓存/DB获取
var result []Channel
for _, ch := range channels {
if prefs.IsEnabled(ch) {
result = append(result, ch)
}
}
return result
}
// 频率限制:同一事件对同一用户限制发送频率
func CheckRateLimit(rdb *redis.Client, userID, event string) bool {
key := fmt.Sprintf("notify:rate:%s:%s", userID, event)
count, _ := rdb.Incr(context.Background(), key).Result()
if count == 1 {
rdb.Expire(context.Background(), key, time.Hour)
}
return count <= 5 // 每小时最多 5 次
}
批量推送
| 场景 | 方案 |
|---|---|
| 单用户通知 | 直接发送 |
| 全员通知 | 分页查用户 → 推入消息队列 → 多消费者并行发送 |
| 定时推送 | 延迟队列 + Cron 触发 |
常见面试问题
Q1: 如何保证通知不丢失?
答案:
- 通知请求先入消息队列(Kafka/RabbitMQ),持久化保障
- 消费者处理后 ACK,失败进入重试队列
- 重试多次失败进入死信队列,人工排查
Q2: 大量用户批量推送怎么处理?
答案:
- 分批查询用户(每批 1000)
- 每批作为一条消息发到 MQ
- 多消费者并行处理
- 控制发送速率避免打垮下游(邮件 SMTP 限速等)
Q3: 站内信未读数怎么实现?
答案:
- 写入时标记
is_read=false - 用 Redis
INCR维护未读计数 - 用户读取时
DECR,批量已读时SET 0