短信服务(二):实现某短信服务商崩溃后自动切换并重试

一、服务商"寄"了

前文的限流 是为了不触发服务商的自我保护机制。但是,万一服务商真的出了问题呢?即便服务商的可用性做到了四个九,也还是有小概率崩溃,怎么办?可以考虑在服务商出了问题的时候,切换到新的服务商

怎么知道服务商出了问题?

  1. 频繁收到超时响应。【可能是服务商那里负载过高】
  2. 收到 EOF 响应或者 UnexpectedEOF 响应。【代表网络通信中断】
  3. 响应时间很长。【响应内容是正常的,但平均响应时间很长】
  4. ...【业务相关的问题】

如果你和服务商的关系很紧密,那么可以询问服务商有没有什么特定的错误码可以判定它已经崩溃。

二、"寄"了该怎么办?

2.1 策略一:failover

思想:出现错误了再换一个服务商,进行重试

(1)failover 的第一种实现方式:轮询全部服务商

实现类:FailOverSmsService

golang 复制代码
package failover

import (
    "context"
    "errors"
    "log"
    "refactor-webook/webook/internal/service/sms"
)

type FailOverSmsService struct {

    // 候选的服务商
    svcs []sms.Service
}

func NewFailOverSmsService(svcs []sms.Service) *FailOverSmsService {
    return &FailOverSmsService{svcs: svcs}
}

func (f *FailOverSmsService) Send(ctx context.Context, tplId string, args []string, numbers ...string) error {
    for _, svc := range f.svcs {
       err := svc.Send(ctx, tplId, args, numbers...)
       // note 若返回 err,则表明该短信服务商不可用,不必处理,遍历下一个
       if err == nil {
          return nil
       }

       log.Println(err)
    }
    return errors.New("轮询了所有服务商,Sms发送失败") // note 到这一步,应该是自己的问题(因为服务商都是高可用的)
}

单元测试 TestFailOverSmsService_Send()

golang 复制代码
package failover

import (
    "context"
    "errors"
    "github.com/stretchr/testify/assert"
    "go.uber.org/mock/gomock"
    "refactor-webook/webook/internal/service/sms"
    smsmocks "refactor-webook/webook/internal/service/sms/mocks"
    "testing"
)

func TestFailOverSmsService_Send(t *testing.T) {
    testCases := []struct {
       name    string
       mock    func(ctrl *gomock.Controller) []sms.Service
       wantErr error
    }{
       {
          name: "一次成功",
          mock: func(ctrl *gomock.Controller) []sms.Service {
             svc0 := smsmocks.NewMockService(ctrl)
             svc0.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
                Return(nil)
             return []sms.Service{svc0}
          },
       },
       {
          name: "第一次失败,第二次成功",
          mock: func(ctrl *gomock.Controller) []sms.Service {
             svc0 := smsmocks.NewMockService(ctrl)
             svc1 := smsmocks.NewMockService(ctrl)
             svc0.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
                Return(errors.New("本服务商崩了"))
             svc1.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
                Return(nil)
             return []sms.Service{svc0, svc1}
          },
       },
       {
          name: "全部失败",
          mock: func(ctrl *gomock.Controller) []sms.Service {
             svc0 := smsmocks.NewMockService(ctrl)
             svc1 := smsmocks.NewMockService(ctrl)
             svc0.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
                Return(errors.New("本服务商崩了"))
             svc1.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
                Return(errors.New("本服务商崩了"))
             return []sms.Service{svc0, svc1}
          },
          wantErr: errors.New("轮询了所有服务商,Sms发送失败"),
       },
    }

    for _, tc := range testCases {
       t.Run(tc.name, func(t *testing.T) {
          ctrl := gomock.NewController(t)
          defer ctrl.Finish()

          svc := NewFailOverSmsService(tc.mock(ctrl))
          err := svc.Send(context.Background(), "my_tpl", []string{"123"}, "138******")
          assert.Equal(t, err, tc.wantErr)
       })
    }
}

缺点: 1)每次都从头开始轮询,绝大多数请求会在 svcs[0] 就成功,导致对不公平 地使用各个短信服务商。2)如果 svcs 有几十个,响应时间都比较长且无法手动控制结束的话,导致轮询都很慢。

(2)failover 的第二种实现方式:从指定服务商轮询

golang 复制代码
// SendV1 可动态指定起始svc,从svc[idx]开始轮询服务商
func (f *FailOverSmsService) SendV1(ctx context.Context, tplId string, args []string, numbers ...string) error {
    // note 保证了即使在多线程或多协程环境下,对该变量的读取和写入操作也是原子的
    // note 确保每次调用 SendV1 时,都会从不同的服务商开始轮询,这样可以公平地分配请求给所有注册的服务商
    idx := atomic.AddUint64(&f.idx, 1)
    length := uint64(len(f.svcs))

    for i := idx; i < idx+length; i++ {
       // note 取余防溢出
       err := f.svcs[i%length].Send(ctx, tplId, args, numbers...)
       // note 根据是否是调用者主动造成的 err 进行分类
       switch err {
       case nil:
          return nil
       case context.Canceled, context.DeadlineExceeded:
          // note 调用者主动取消 和 ctx超时,结束轮询
          return err
       }
       log.Println(err)
    }
    return errors.New("轮询了所有服务商,Sms发送失败")
}

注意其中的原子操作 idx := atomic.AddUint64(&f.idx, 1) :并不是用锁实现的严格轮询 ,而是根据起始svc轮询 ,【考虑到加锁会影响性能】如下图,左图是严格轮询,右图是原子操作下的轮询:

对第一种方式的改进: 1)起始 svc[idx] 是动态的,且实现了公平地分配请求给所有注册的服务商 。2)可因为 context 过期终止而结束轮询。

拓展一下 原子操作

原子操作是轻量级并发工具面试中一种并发优化的思路,就是使用原子操作。

原子操作在 atomic 包中,注意原子操作操作的都是指针。如上述代码中的 idx := atomic.AddUint64(&f.idx, 1) 中的 &f

记住一个原则:任何变量的任何操作,在没有并发控制的情况下,都不是并发安全的

对比原子操作和加锁:

原子操作的使用场景: 1)对单个变量进行简单的更新,如递增、递减、比较并交换等。 2)高并发场景下,需要快速执行的简单操作。

加锁操作的使用场景: 1) 保护复杂的数据结构或执行复杂的算法,其中涉及多个变量的修改 。 2) 需要精细控制并发访问的场景,例如数据库事务处理或大型数据结构的修改。

2.1 策略二:动态判定服务商状态

请见下文(doge)

相关推荐
AskHarries2 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
isolusion3 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp3 小时前
Spring-AOP
java·后端·spring·spring-aop
我是前端小学生4 小时前
Go语言中的方法和函数
go
TodoCoder4 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
凌虚5 小时前
Kubernetes APF(API 优先级和公平调度)简介
后端·程序员·kubernetes
机器之心5 小时前
图学习新突破:一个统一框架连接空域和频域
人工智能·后端
.生产的驴6 小时前
SpringBoot 对接第三方登录 手机号登录 手机号验证 微信小程序登录 结合Redis SaToken
java·spring boot·redis·后端·缓存·微信小程序·maven
顽疲6 小时前
springboot vue 会员收银系统 含源码 开发流程
vue.js·spring boot·后端
机器之心6 小时前
AAAI 2025|时间序列演进也是种扩散过程?基于移动自回归的时序扩散预测模型
人工智能·后端