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

一、服务商"寄"了

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

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

  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)

相关推荐
间彧1 分钟前
Kubernetes的Pod与Docker Compose中的服务在概念上有何异同?
后端
间彧5 分钟前
从开发到生产,如何将Docker Compose项目平滑迁移到Kubernetes?
后端
间彧11 分钟前
如何结合CI/CD流水线自动选择正确的Docker Compose配置?
后端
间彧12 分钟前
在多环境(开发、测试、生产)下,如何管理不同的Docker Compose配置?
后端
间彧13 分钟前
如何为Docker Compose中的服务配置健康检查,确保服务真正可用?
后端
间彧17 分钟前
Docker Compose和Kubernetes在编排服务时有哪些核心区别?
后端
间彧23 分钟前
如何在实际项目中集成Arthas Tunnel Server实现Kubernetes集群的远程诊断?
后端
brzhang1 小时前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构
草明2 小时前
Go 的 IO 多路复用
开发语言·后端·golang
蓝-萧2 小时前
Plugin ‘mysql_native_password‘ is not loaded`
java·后端