短信平台开发方案:流量控制与短信不丢失保障

112 阅读5分钟

前言

开发一个手机短信平台,短信平台已经接入各渠道商的短信功能,并且向外提供一个短信提交接口。业务侧可以直接调用接口进行短信提交,而短信平台根据各业务绑定的通道分发至对应的渠道商,从而实现短信的提交。再此过程中,主要考虑解决以下问题:

  • 如果有很多业务提交短信,导致流量突增
  • 保证提交的短信不丢失,提交失败短信自动重试机制,保证不失败

设计方案

总体时序图

整体流程:

  • 业务侧调用短信提交接口提交一笔短信提交
  • 短信服务获取业务唯一标识AppID,获取对应的绑定短信通道
  • 将短信转发给对应短信通道接口
  • 短信通道的返回结果保存至 Redis 中
  • 计划任务异步从redis 中提取短信提交和响应结果保存至DB

场景问题1:流量暴增

基于短信每日提交的场景,是基本要保证平稳的流量,所以采用限流的方案。常用的限流的方案:

  • 使用 Redis 分布式限流
  • 使用Guava RateLimiter或Sentinel限制单用户/IP请求频率
  • 使用令牌桶算法、漏桶算法限流

使用令牌桶算法实现接口限流方式,实现简单且性能高效,所以这里主要说下使用令牌桶算法限流方案:

type TokenBucket struct {
    Capacity     int           // 桶容量
    Tokens       int           // 当前令牌数
    RefillRate   time.Duration // 添加间隔
    LastRefill   time.Time     // 上次添加时间
    Mu           sync.Mutex    // 互斥锁
}

基本算法原理:

  • 固定容量桶存放令牌
  • 以恒定速率向桶添加令牌(如10个/秒)
  • 请求需获取令牌才能被处理
  • 桶空时拒绝请求

增加使用 Redis 缓存桶令牌,限流额度等信息,可适用于分布式场景。可以根据不同限流维度进行限流,如用户/IP/接口等等,并将其作为缓存Key,再将令牌信息作为值。

Key: rate_limit:{bucket_key} (如根据用户进行限流 rate_limit:user_123)

Value:
    tokens: 当前令牌数量
    last_refill: 上次补充时间戳(毫秒)
    capacity: 桶容量
    rate: 填充速率(毫秒/令牌)

// 检查是否有可用的令牌
func (tb *TokenBucket) Allow() bool {
    tb.Mu.Lock()
    defer tb.Mu.Unlock()
    
    // 补充令牌
    now := time.Now()
    tokensToAdd := int(now.Sub(tb.LastRefill) / tb.RefillRate)
    if tokensToAdd > 0 {
        tb.Tokens = min(tb.Capacity, tb.Tokens + tokensToAdd)
        tb.LastRefill = now
    }
    
    // 检查令牌
    if tb.Tokens > 0 {
        tb.Tokens--
        return true
    }
    return false
}

使用时仅需在请求前进行路由拦截,每个请求进来时都需要调用 Allow 方法判断是否有可用的令牌,同时也需要对令牌桶中的令牌进行增减补充。

有个问题:如果使用过程中redis 发生故障,应该如何进行处理?采用方式是故障降级方法来保证服务的高可用,避免其导致服务不可用。

更进一步优化是对不同故障类型选择不同的策略:返回本地缓存状态、完全放行、部分拒绝等等(具体问题具体分析)。

func RedisRateLimitMiddleware(redisClient *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. 构建用户维度的限流键 
        identifier := "user_" + getUserId(c) 
        bucketKey := "rate_limit:" + identifier
        
        // 2. 执行限流脚本
        allowed, err := allowRequest(redisClient, bucketKey, 100, 1000) // 容量100, 1000ms/令牌

        // Redis故障处理:直接放行
        if err != nil { 
            c.Next()  
            return
        }
        
        if !allowed {
            // 3. 触发限流
            c.Header("Retry-After", "1")
            c.JSON(429, gin.H{"error": "rate limit exceeded"})
            c.Abort()
            return
        }
        
        // 4. 直接放行请求
        c.Next()
    }
}

func allowRequest(redisClient *redis.Client, key string) (bool, error) {
    // 尝试Redis限流
    result, err := redisClient.get(Key).Result()
    if result.Tokens > 0 {
        return true
    }
    
    // Redis故障降级处理
    if err != nil {
        if errors.Is(err, redis.Nil) {
            return true, nil // Key不存在则放行
        }
        // 根据故障类型选择策略:
        // 1. 返回本地缓存状态
        // 2. 完全放行
        // 3. 部分拒绝
        return fallbackStrategy(), nil
    }
    return result.(int) == 1, nil
}

场景问题2:保证短信不失败

基于Redis实现

如果只使用Redis,保证Redis高可用也同样是可以实现功能。

  • 接收短信的提交直接调用第三方接口转发短信提交
  • 将短信提交和响应结果都持久化至Redis中
  • 计划任务Task1:读取Redis 将短信存储至DB中
  • 计划任务Task2:监控DB中提交状态,若是异常的状态则进行重试
基于MQ实现

基于以上的设计,短信提交后确保短信不丢失,需要建立一个可靠的处理流程。

根据以上流程图,可以看到接收短信后进行同步持久化写入:

  • 主路线完成存储数据库,转发第三方接口,再发送处理结果
  • 子路线写入消息队列队列中
  • 主路线中若处理异常则再启动对异常的结果的短信进行重试

通过使用数据库+消息队列双重持久化保证消息不丢失,这样即使遇到第三方服务故障、网络中断等异常情况时都可以保证短信不回丢失,并通过重新机制最终成功发送。

// 接收短信
func handleSmsSubmit(w http.ResponseWriter, r *http.Request) {
   
    sms := parseRequest(r)
    
    // 1. 写入数据库(主存储)
    if err := db.SaveSMS(sms); err != nil {
        log.Printf("数据库写入失败: %v", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    
    // 2. 写入消息队列(备份)
    if err := mq.Publish(sms); err != nil {
        log.Printf("消息队列写入失败: %v", err)
        // 此时数据库已有记录,可后续处理
    }
    
    // 3. 异步转发给第三方
    go forwardToThirdParty(sms)
    
    w.WriteHeader(http.StatusAccepted)
}

// 重试机制
func forwardToThirdParty(sms SMS) {
    maxRetries := 3
    backoff := []time.Duration{1 * time.Second, 5 * time.Second, 30 * time.Second}
    
    for i := 0; i <= maxRetries; i++ {
        resp, err := forwardToThirdParty(sms)
        
        if err == nil && resp.StatusCode == 200 {
            // 更新数据库状态为已发送
            db.UpdateStatus(sms.RequestID, "sent")
            return
        }
        
        // 记录错误信息
        errorMsg := fmt.Sprintf("尝试 %d 失败: %v", i+1, err)
        db.RecordError(sms.RequestID, errorMsg)
        
        if i < maxRetries {
            time.Sleep(backoff[i])
        }
    }
    
    // 所有重试失败,标记为失败状态
    db.UpdateStatus(sms.RequestID, "failed")
}