Skip to content

微服务架构:

  • 将应用程序拆分为小而自治的服务的软件架构模式。每个服务专注于执行特定的业务功能,并通过轻量级的通信机制进行交互。

  • 每个服务有自己的代码库、数据库等,可以有不同的技术栈

  • 优势

    • 独立开发和部署
    • 松耦合:服务间通过网络通信,单个服务可以轻松更换和升级组件
    • 高可扩展性:实例扩缩容简单
    • 技术多样性:选择合适的技术栈
    • 弹性和容错性:一个服务故障,其他服务还能使用
    • 敏捷开发:任何功能都可以快速开发
  • 劣势:

    • 复杂度高:需要服务发现、负载均衡、监控和日志等基础设施
    • 分布式挑战:如网络延迟、一致性、事务管理等
    • 通信开销:不同服务间通信有开销和时延
    • 排查问题:出错后由于链路长,排查较为困难
  • SOA 和微服务架构

  • 组件:

    • 微服务:单体功能
    • 服务通信:RPC、HTTP 等
    • 服务发现:
      • 注册:IP、端口
      • 发现:
      • 健康检查:定期检测
      • 负载均衡
      • 动态更新:
      • 故障转移:
        • 熔断:
          • 打开:当依赖服务频繁失败,会暂停请求
          • 半打开:过一段时间开始尝试少量请求,探测服务是否恢复
          • 关闭:半打开请求都成功,恢复正常调用
        • 降级:在服务熔断或报错时,使用兜底方案(比如返回默认值)
      • 流量控制:
        • 限流:
          • goo 中有 golang.org/x/time/rate
    • 监控和日志:
    • 部署和扩展:CICD
  • 耦合:组件之间依赖关系强度的度量被认为是耦合。一个好的设计总是被认为具有高内聚力和低耦合性。

  • QPS(Queries Per Second):每秒查询数

    • 获取方案:
      • promethues counter 打点
    • 计算方式:QPS = (cnt(t) - cnt(t - Δt)) / Δt
      • 一般会将每一个时间段的计数放入数组(数组每个元素称为 bucket),比如每 1 的数据,粒度越细,服务治理越好,但是成本越高
      • 一般内存数组是环形数组,过期的落库
      • 那么一段时间的 QPS 就可以对这段时间内的 bucket 值求和获得
    • 平均耗时:用一个额外的 bucket 存放这个时间段内的总耗时(类似于一行两列数组)
      • 平均耗时:Latency总和/cnt总和
      • 可以使用 sentinel-golang组件实现
    • 常见场景 QPS
      • 多模态推理:小于 100
      • 文本推理:小于 400
      • 写数据库:小于 5k
      • 读数据库:小于 10k
      • 读缓存:大于 10k

参考 Nagle 算法的做法

我们熟悉的 TCP 协议里,有个算法叫 Nagle 算法,设计它的目的,就是为了避免一次传过少数据,提高数据包的有效数据负载。

当我们想要发送一些数据包时,数据包会被放入到一个缓冲区中,不立刻发送,那什么时候会发送呢?

数据包会在以下两个情况被发送:

  • 缓冲区的数据包长度达到某个长度(MSS)时。
  • 或者等待超时(一般为200ms)。在超时之前,来的那么多个数据包,就是凑不齐 MSS 长度,现在超时了,不等了,立即发送。

这个思路就非常值得我们参考。我们完全可以自己在代码层实现一波,实现也非常简单。

1.我们定义一个带锁的全局队列(链表)。

2.当上游服务输入一个视频和它对应的 N 张图片时,就加锁将这 N 张图片数据和一个用来存放返回结果的结构体放入到全局队列中。然后死循环读这个结构体,直到它有结果。就有点像阻塞等待了。

3.同时在服务启动时就起一个线程 A专门用于收集这个全局队列的图片数据。线程 A负责发起调用下游服务的请求,但只有在下面两个情况下会发起请求

  • 当收集的图片数量达到 xx 张的时候

  • 距离上次发起请求过了 xx 毫秒(超时)

    4.调用下游结束后,再根据一开始传入的数据,将调用结果拆开来,送回到刚刚提到的用于存放结果的结构体中。

    5.第 2 步里的死循环因为存放返回结果的结构体,有值了,就可以跳出死循环,继续执行后面的逻辑。

batch_call同步调用改造

这就像公交车站一样,公交车站不可能每来一个顾客就发一辆公交车,当然是希望车里顾客越多越好。上游每来一个请求,就把请求里的图片,也就是乘客,塞到公交车里,公交车要么到点发车(向下游服务发起请求),要么车满了,也没必要等了,直接发车。这样就保证了每次发车的时候公交车里的顾客数量足够多,发车的次数尽量少。


大体思路就跟上面一样,如果是用 go 来实现的话,就会更加简单。

比如第 1 步里的加锁全局队列可以改成有缓冲长度的 channel。第 2 步里的"用来存放结果的结构体",也可以改成另一个无缓冲 channel。执行 res := <-ch, 就可以做到阻塞等待的效果。

而核心的仿 Nagle 的代码也大概长下面这样。当然不看也没关系,反正你已经知道思路了

go
func CallAPI() error {
	size := 100
	// 这个数组用于收集视频里的图片,每个 IVideoInfo 下都有N张图片
	videoInfos := make([]IVideoInfo, 0, size)
	// 设置一个200ms定时器
	tick := time.NewTicker(200 * time.Microsecond)
	defer tick.Stop()
  // 死循环
	for {
		select {
		// 由于定时器,每200ms,都会执行到这一行
		case <-tick.C:
			if len(videoInfos) > 0 {
				// 200ms超时,去请求下游
				limitStartFunc(videoInfos, true)
				// 请求结束后把之前收集的数据清空,重新开始收集。
				videoInfos = make([]IVideoInfo, 0, size)
			}
		// AddChan就是所谓的全局队列
		case videoInfo, ok := <-AddChan:
			if !ok {
				// 通道关闭时,如果还有数据没有去发起请求,就请求一波下游服务
				limitStartFunc(videoInfos, false)
				videoInfos = make([]IVideoInfo, 0, size)
				return nil
			} else {
				videoInfos = append(videoInfos, videoInfo)
				if videoInfos 内的图片满足xx数量 {
					limitStartFunc(videoInfos, false)
					videoInfos = make([]IVideoInfo, 0, size)
					// 重置定时器
					tick.Reset(200 * time.Microsecond)
				}
			}
		}
	}
	return nil
}

通过这一操作,上游每来一个请求,都会将视频里的图片收集起来,堆到一定张数的时候再统一请求,大大提升了每次 batch call 的图片数量,同时也减少了调用下游服务的次数。真·一举两得

优化的效果也比较明显,上游服务支持的 qps 从原来不稳定的 3q~15q 变成稳定的 90q。下游的接口耗时也变得稳定多了,从原来的过山车似的飙到 15s 变成稳定的 500ms 左右。处理的图片的速度也从原来 20qps 提升到 350qps。

到这里就已经大大超过业务需求的预期(40qps)了,够用就好,多一个 qps 都是浪费。

可以了,下班吧。

正在精进