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

前言

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

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

设计方案

总体时序图

整体流程:

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

场景问题1:流量暴增

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

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

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

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

基本算法原理:

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

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

yaml 复制代码
Key: rate_limit:{bucket_key} (如根据用户进行限流 rate_limit:user_123)

Value:
    tokens: 当前令牌数量
    last_refill: 上次补充时间戳(毫秒)
    capacity: 桶容量
    rate: 填充速率(毫秒/令牌)
go 复制代码
// 检查是否有可用的令牌
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 发生故障,应该如何进行处理?采用方式是故障降级方法来保证服务的高可用,避免其导致服务不可用。

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

go 复制代码
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实现

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

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

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

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

go 复制代码
// 接收短信
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")
}
相关推荐
他日若遂凌云志2 小时前
深入剖析 Fantasy 框架的消息设计与序列化机制:协同架构下的高效转换与场景适配
后端
快手技术2 小时前
快手Klear-Reasoner登顶8B模型榜首,GPPO算法双效强化稳定性与探索能力!
后端
二闹2 小时前
三个注解,到底该用哪一个?别再傻傻分不清了!
后端
用户49055816081252 小时前
当控制面更新一条 ACL 规则时,如何更新给数据面
后端
林太白2 小时前
Nuxt.js搭建一个官网如何简单
前端·javascript·后端
码事漫谈2 小时前
VS Code 终端完全指南
后端
该用户已不存在3 小时前
OpenJDK、Temurin、GraalVM...到底该装哪个?
java·后端
怀刃3 小时前
内存监控对应解决方案
后端
码事漫谈3 小时前
VS Code Copilot 内联聊天与提示词技巧指南
后端
Moonbit3 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞 (上):编译前端实现
后端·算法·编程语言