一、需求
设计一个新的容错机制,同步转异步的容错机制。当满足以下两个条件(触发了限流、判定服务商已经崩溃)中的任何一个时,将请求转储到数据库(画个饼:过几天更新消息队列的版本 doge),后续再另外启动一个 goroutine 异步发送出去。
二、方案
(1)何时转异步发送和何时退出异步发送?
问题1:如何转成异步发送?
需要根据业务指标设计,以下是两种情况:服务商限流和崩溃。若是前者的原因,可基于服务商返回的特定的错误码来判断,进而需要异步发送;若是后者的原因,可基于 1. 响应时间 2. 错误率 等来判定服务商已崩溃,需要异步发送
问题2:为什么时候退出异步?
可基于 1. 固定时间 2. 流量比例来决定何时退出异步发送
(2)服务商短信接口的各种参数是怎么统一存储到数据库中的?
该项目中短信服务的发送接口参数有tplId、args[]和 numbers[]。因为数据库驱动支持的 Value 类型中没有切片类型,且考虑到在开发前期可以灵活处理存储数据以适应数据模型的变化,所以我创建了一个支持 泛型 的 JsonColumn 类,实现了 driver 的 Valuer 和 Scanner 接口,以支持将 Go 结构体类型转换为 Json 并存储到数据库和从数据库中读取 JSON 字符串并将其反序列化为 Go 结构体。
简而言之,
Valuer和Scanner接口的存在是为了让 Go 语言中的自定义 数据类型能够无缝地与数据库进行交互,而无需修改数据库的结构或者编写大量的转换代码。这极大地提高了代码的可维护性和扩展性。参考:gorm.io/zh_CN/docs/…
JsonColumn 的字段有 Val 和 Valid,其中 Valid 是为了区分 零值 和 nil 的对象,以便于在实现 Valuer 和 Scanner 接口的方法中有不同的处理方式。
(4)使用了怎样的调度机制实现了并发环境下的异步查询待发送的sms
版本一:基于行级锁的抢占式调度
在启动异步发送服务时,开启一个 Goroutine 来循环的执行异步发送。该异步发送过程中,先抢占数据库表中的一条等待发送的记录,若抢占成功,则执行 Send() 并向数据库报告发送结果;若抢占失败,则睡眠 1s,缓解网络抖动问题。
行级锁的相关知识:
参考:
- 行级锁的原理:gorm.io/zh_CN/docs/…
- GORM如何使用行级锁:www.xiaolincoding.com/mysql/lock/…
版本二:Kafka 消息队列
还可以利用消息队列实现回调业务,也就是你允许用户回复你的短信,而后你回调业务方。最简单的做法就是你把用户回复的内容丢到消息队列的一个 topic 里面,业务方去订阅。
日后更新如何实现(画饼.jpg)
(5)如何保证单个短信服务请求的重试时间间隔是固定的?
在 dao 层利用 Utime < now - minute 的条件来 select 一分钟前的 status = waiting 的短信请求。因为当一个短信请求被存储且 Utime = now 后,意味着在接下来的一分钟内,该任务不会再被其他实例作为等待状态的任务获取。同时,这种机制有助于降低在高并发环境下对数据库的访问频率,减轻数据库的压力。(也是因为要高频查询 Utime,所以要为其添加 index)
三、实现
目录结构
(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)
本短信服务合集的文章采用的方式包括:(旨在提高可用性)
- 自动重试:提供自动重试功能。在与服务商交互时,为每个请求设置超时时间。如果请求超时,可以先进行几次重试,如果重试仍然失败,则认为服务商可能已崩溃。
- 限流:为短信服务平台提供限流功能,避免服务商为我们限流。设置阈值为 3000/s。
failover策略:利用failover策略,实现服务商崩溃后的自动切换。- 动态计算服务商状态:采用是否 “连续N个响应超时” 的判断来动态计算服务商状态。
- 同步转异步:提高可用性。
- 权限控制:为公司内部提供静态
token控制短信服务的成本和安全。