一、服务商"寄"了
前文的限流 是为了不触发服务商的自我保护机制。但是,万一服务商真的出了问题呢?即便服务商的可用性做到了四个九,也还是有小概率崩溃,怎么办?可以考虑在服务商出了问题的时候,切换到新的服务商。
怎么知道服务商出了问题?
- 频繁收到超时响应。【可能是服务商那里负载过高】
- 收到 EOF 响应或者 UnexpectedEOF 响应。【代表网络通信中断】
- 响应时间很长。【响应内容是正常的,但平均响应时间很长】
- ...【业务相关的问题】
如果你和服务商的关系很紧密,那么可以询问服务商有没有什么特定的错误码可以判定它已经崩溃。
二、"寄"了该怎么办?
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)