短信服务(四):设计同步转异步的容错机制

540 阅读8分钟

一、需求

设计一个新的容错机制,同步转异步的容错机制。当满足以下两个条件(触发了限流判定服务商已经崩溃)中的任何一个时,将请求转储到数据库(画个饼:过几天更新消息队列的版本 doge),后续再另外启动一个 goroutine 异步发送出去。

image.png

二、方案

(1)何时转异步发送和何时退出异步发送?

问题1:如何转成异步发送

需要根据业务指标设计,以下是两种情况:服务商限流和崩溃。若是前者的原因,可基于服务商返回的特定的错误码来判断,进而需要异步发送;若是后者的原因,可基于 1. 响应时间 2. 错误率 等来判定服务商已崩溃,需要异步发送 image.png

问题2:为什么时候退出异步?

基于 1. 固定时间 2. 流量比例来决定何时退出异步发送

image.png

(2)服务商短信接口的各种参数是怎么统一存储到数据库中的?

该项目中短信服务的发送接口参数有tplIdargs[]numbers[]。因为数据库驱动支持的 Value 类型中没有切片类型,且考虑到在开发前期可以灵活处理存储数据以适应数据模型的变化,所以我创建了一个支持 泛型 的 JsonColumn 类,实现了 driverValuerScanner 接口,以支持将 Go 结构体类型转换为 Json 并存储到数据库和从数据库中读取 JSON 字符串并将其反序列化为 Go 结构体。

简而言之,ValuerScanner 接口的存在是为了让 Go 语言中的自定义 数据类型能够无缝地与数据库进行交互,而无需修改数据库的结构或者编写大量的转换代码。这极大地提高了代码的可维护性和扩展性。参考:gorm.io/zh_CN/docs/…

JsonColumn 的字段有 ValValid,其中 Valid 是为了区分 零值nil 的对象,以便于在实现 ValuerScanner 接口的方法中有不同的处理方式。

(4)使用了怎样的调度机制实现了并发环境下的异步查询待发送的sms

版本一:基于行级锁的抢占式调度

在启动异步发送服务时,开启一个 Goroutine 来循环的执行异步发送。该异步发送过程中,先抢占数据库表中的一条等待发送的记录,若抢占成功,则执行 Send() 并向数据库报告发送结果;若抢占失败,则睡眠 1s,缓解网络抖动问题。

行级锁的相关知识:

参考:

  1. 行级锁的原理:gorm.io/zh_CN/docs/…
  2. GORM如何使用行级锁:www.xiaolincoding.com/mysql/lock/…

版本二:Kafka 消息队列

image.png

还可以利用消息队列实现回调业务,也就是你允许用户回复你的短信,而后你回调业务方。最简单的做法就是你把用户回复的内容丢到消息队列的一个 topic 里面,业务方去订阅。

image.png

日后更新如何实现(画饼.jpg)

(5)如何保证单个短信服务请求的重试时间间隔是固定的?

dao 层利用 Utime < now - minute 的条件来 select 一分钟前的 status = waiting 的短信请求。因为当一个短信请求被存储且 Utime = now 后,意味着在接下来的一分钟内,该任务不会再被其他实例作为等待状态的任务获取。同时,这种机制有助于降低在高并发环境下对数据库的访问频率,减轻数据库的压力。(也是因为要高频查询 Utime,所以要为其添加 index

三、实现

目录结构

image.png

(1)Service 层

package async

import (
    "context"
    "refactor-webook/webook/internal/sms_project/domain"
    "refactor-webook/webook/internal/sms_project/repository"
    "refactor-webook/webook/internal/sms_project/service"
    "refactor-webook/webook/pkg/logger"
    "time"
)

type AsyncService struct {
    svc  service.Service
    repo repository.AsyncSmsRepository
    l    logger.LoggerV1
}

func NewAsyncService(svc service.Service, repo repository.AsyncSmsRepository) *AsyncService {
    res := &AsyncService{svc: svc, repo: repo}
    // 创建service后就开启异步发送消息的线程
    go func() {
       res.StartAsyncCycle()
    }()
    return res
}

func (s *AsyncService) StartAsyncCycle() {
    for {
       s.AsyncSend()
    }
}

func (s *AsyncService) AsyncSend() {
    // 不断询问数据库有无waiting的sms的过程中,避免无限制的阻塞 ==> 创建一个限时的ctx
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    // note 基于 数据库行级锁 的机制,实现了抢占式调度
    // note 确保在 K8s环境中部署的多个 Pod 中,针对同一个异步短信发送请求,只有一个 Pod 实例能够成功获取并处理
    asyncSms, err := s.repo.PreemptWaitingSms(ctx)
    cancel() // note 执行完数据库操作就立即 cancel 掉 ctx (在哪里创建ctx就在哪里销毁)

    switch err {
    case nil:
       // 需要执行 发送 的操作,执行之前创建一个 ctx(和 ReportScheduleResult() 共用一个 ctx)
       ctx, cancel = context.WithTimeout(context.Background(), time.Second)
       defer cancel()
       err = s.svc.Send(ctx, asyncSms.TplId, asyncSms.Args, asyncSms.Numbers...)
       if err != nil {
          s.l.Error("抢占成功,但发送短信失败", logger.Error(err), logger.Int64("id", asyncSms.Id))
       }
       // 若 err != nil,则 success = false
       success := err == nil
       // note 通知 repository 我这一次的执行结果
       err = s.repo.ReportScheduleResult(ctx, asyncSms.Id, success)
       if err != nil {
          s.l.Error("抢占和发送成功,但标记数据库失败",
             logger.Error(err),
             logger.Int64("id", asyncSms.Id),
             logger.Bool("res", success))
       }
    case repository.ErrWaitingSmsNotFound:
       // 没有需要异步发送的消息,可以睡眠一会等一等
       time.Sleep(time.Second)
    default:
       // 一般是数据库出了问题,但是为了尽量运行,还是要继续的
       // 睡眠可以规避掉 短时间的网络抖动问题
       s.l.Error("抢占式异步发送短信失败", logger.Error(err))
       time.Sleep(time.Second)
    }
}

func (s *AsyncService) Send(ctx context.Context, tplId string, args []string, numbers ...string) error {
    if s.needAsync() {
       // 根据业务指标,该sms需要异步发送 ==> 先存入数据库,再等待异步发送
       err := s.repo.Add(ctx, domain.AsyncSms{
          TplId:   tplId,
          Args:    args,
          Numbers: numbers,

          RetryMax: 3, // note 设置可以重试 3 次
       })
       return err
    }
    // 不需要异步发送
    return s.svc.Send(ctx, tplId, args, numbers...)

}

func (s *AsyncService) needAsync() bool {
    // note 需要根据业务指标设计的,各种判定要不要触发异步的方案(判定服务商崩溃 和 触发了限流)
    // 1. 基于响应时间
    //        1.1 绝对阈值,比如连续N个请求响应时间超过了 500ms,后续请求就转异步
    //        1.2 变化趋势,比如当前一秒钟内的所有请求的响应时间比上一秒钟增长了 X%,后续请求就转异步
    // 2. 基于错误率:
    //        2.1 一段时间内,收到 err 的请求比率大于 X%,后续请求就转异步
    //        2.2 只要出现 EOF或者其他网络的error,后续请求就转异步
    // 3, 被服务商限流
    //        3.1 基于服务商返回的特定的错误码

    // note 什么时候退出异步?
    // 1. 固定时间:进入异步 N 分钟后
    // 2. 流量比例:保留 10% 的流量(或者更少)进行同步发送,根据这部分请求的响应时间/错误率,进一步增大同步请求的比例
    //        怎么控制 10%进行同步? ===》 用随机数:设置0-100的随机数,当 <10时就同步,当 >10时就异步

    return true
}

(2)Repository 层

package repository

import (
    "context"
    "refactor-webook/webook/internal/sms_project/domain"
    "refactor-webook/webook/internal/sms_project/repository/dao"
    "refactor-webook/webook/pkg/sqlx"
)

var ErrWaitingSmsNotFound = dao.ErrWaitingSmsNotFound

type AsyncSmsRepository interface {
    Add(ctx context.Context, sms domain.AsyncSms) error
    PreemptWaitingSms(ctx context.Context) (domain.AsyncSms, error)
    ReportScheduleResult(ctx context.Context, id int64, success bool) error
}

type asyncSmsRepository struct {
    dao dao.AsyncSmsDao
}

func (a *asyncSmsRepository) Add(ctx context.Context, sms domain.AsyncSms) error {
    err := a.dao.Insert(ctx, dao.AsyncSms{
       Id: sms.Id,
       Config: sqlx.JsonColumn[dao.SmsConfig]{
          Val: dao.SmsConfig{
             TplId:   sms.TplId,
             Args:    sms.Args,
             Numbers: sms.Numbers,
          },
          Valid: true,
       },
       RetryMax: sms.RetryMax,
    })
    return err
}

func (a *asyncSmsRepository) PreemptWaitingSms(ctx context.Context) (domain.AsyncSms, error) {
    waitingSms, err := a.dao.GetWaitingSms(ctx)
    if err != nil {
       return domain.AsyncSms{}, err
    }
    return domain.AsyncSms{
       Id:       waitingSms.Id,
       TplId:    waitingSms.Config.Val.TplId,
       Args:     waitingSms.Config.Val.Args,
       Numbers:  waitingSms.Config.Val.Numbers,
       RetryMax: waitingSms.RetryMax,
    }, nil
}

// ReportScheduleResult 抢占行级锁成功的情况下,Send() 成功与否
func (a *asyncSmsRepository) ReportScheduleResult(ctx context.Context, id int64, success bool) error {
    if success {
       return a.dao.MarkSuccess(ctx, id)
    }
    return a.dao.MarkFailed(ctx, id)
}

(3)dao 层

package dao

import (
    "context"
    "gorm.io/gorm"
    "gorm.io/gorm/clause"
    "refactor-webook/webook/pkg/sqlx"
    "time"
)

var ErrWaitingSmsNotFound = gorm.ErrRecordNotFound

type AsyncSms struct {
    Id       int64
    Config   sqlx.JsonColumn[SmsConfig]
    RetryMax int64
    Status   int8
    Ctime    int64
    Utime    int64 `gorm:"index"`
}

type SmsConfig struct {
    TplId   string
    Args    []string
    Numbers []string
}

const (
    asyncStatusWait = iota
    asyncStatusFailed
    asyncStatusSuccess
)

type AsyncSmsDao interface {
    Insert(ctx context.Context, sms AsyncSms) error
    GetWaitingSms(ctx context.Context) (AsyncSms, error)
    MarkSuccess(ctx context.Context, id int64) error
    MarkFailed(ctx context.Context, id int64) error
}

type GormAsyncSmsDao struct {
    db *gorm.DB
}

func (g *GormAsyncSmsDao) Insert(ctx context.Context, sms AsyncSms) error {
    // 设置 status 和 时间
    sms.Status = asyncStatusWait
    now := time.Now().UnixMilli()
    sms.Ctime = now
    sms.Utime = now

    return g.db.WithContext(ctx).Create(&sms).Error
}

// GetWaitingSms 先 select 一个 waitingSms, 再 update 它的 retry_cnt 和 Utime(在一个事务中完成,使用行级锁)
func (g *GormAsyncSmsDao) GetWaitingSms(ctx context.Context) (AsyncSms, error) {
    var sms AsyncSms
    // note 行级锁需要在事务中使用,因为当事务提交了,锁就会被释放
    err := g.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
       now := time.Now().UnixMilli()
       endTime := now - time.Minute.Milliseconds()
       // note select for update 行级锁中的独占锁
       err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("utime < ? and status = ?", endTime, asyncStatusWait).
          First(&sms).Error
       if err != nil {
          return err
       }

       // 更新 utime 和 retry_cnt
       err = tx.Model(&AsyncSms{}).Where("id = ?", sms.Id).Updates(map[string]any{
          "utime":     now,
          "retry_cnt": gorm.Expr("retry_cnt + 1"),
       }).Error // note 不需要检查 res.RowsAffected == 0,因为记录一定存在

       return err
    })
    return sms, err
}

// MarkSuccess 抢占成功,发送成功 ==> 修改 status 和 utime
func (g *GormAsyncSmsDao) MarkSuccess(ctx context.Context, id int64) error {
    return g.db.WithContext(ctx).Where("id = ?", id).Updates(map[string]any{
       "status": asyncStatusSuccess,
       "utime":  time.Now().UnixMilli(),
    }).Error

}

// MarkFailed 抢占成功,发送失败 ==> 只有到了重试最大次数再更新 status 和 utime
func (g *GormAsyncSmsDao) MarkFailed(ctx context.Context, id int64) error {
    return g.db.WithContext(ctx).Where("id = ? and retry_cnt >= `retry_max`", id).
       // note `retry_max` 是当前记录的一个字段
       Updates(map[string]any{
          "status": asyncStatusFailed,
          "utime":  time.Now().UnixMilli(),
       }).Error
}
package dao

import "gorm.io/gorm"

func InitTables(db *gorm.DB) error {

    // 自动建表会有字段不符合公司标准的风险
    return db.AutoMigrate(
       &AsyncSms{},
    )
}

(4)domain

package domain

type AsyncSms struct {
    Id      int64
    TplId   string
    Args    []string
    Numbers []string

    RetryMax int64
}

四、总结

基本上任何公司都难免要和第三方打交道(如微信的接口、阿里的服务等),你找到这一类代码,而后设计一些治理措施,来提高可用性扩展性或安全性,可用来刷KPI(doge)

短信服务合集的文章采用的方式包括:(旨在提高可用性

  1. 自动重试:提供自动重试功能。在与服务商交互时,为每个请求设置超时时间。如果请求超时,可以先进行几次重试,如果重试仍然失败,则认为服务商可能已崩溃。
  2. 限流:为短信服务平台提供限流功能,避免服务商为我们限流。设置阈值为 3000/s。
  3. failover 策略:利用 failover 策略,实现服务商崩溃后的自动切换。
  4. 动态计算服务商状态:采用是否 “连续N个响应超时” 的判断来动态计算服务商状态。
  5. 同步转异步:提高可用性。
  6. 权限控制:为公司内部提供静态 token控制短信服务的成本和安全。