几个预防并发搞垮下游服务的方法

前言

上一篇文章 我用休眠做并发控制,搞垮了下游服务 发出去后得到不少网友的回应,有人问自己平时用的方案行不行,有人建议借鉴TCP的拥塞控制策略,动态地调整发起的并发数,还有人问为啥我要管下游抗不抗得住。

今天我就来总结几种调用下游服务时做并发控制的方案。

因为我们这篇文章是科普向的文章,主要目的是总结一下应该怎么在享受并发带来效率提升的同时做好并发控制让整个系统的上下游都能更稳定一些,不对限流、控制到底该哪个服务加,出了事故谁负责做讨论。

并发控制方案

前面我们提到用休眠做并发控制的最大弊端是,没有考虑下游服务的感受,每次开固定数量的goroutine 去执行任务后,调用者休眠 1s 再来,而不是等待下游服务的反馈再开启下一批任务执行。

func badConcurrency() {
 batchSize := 500
 for {
  data, _ := queryDataWithSizeN(batchSize)
  if len(data) == 0 {
   break
  }

  for _, item := range data {
   go func(i int) {
    doSomething(i)
   }(item)
  }

  time.Sleep(time.Second * 1)
 }
}

此外上游还有请求分配不均的问题,休眠的时候完全没有请求,休眠结束后不管下游有没有执行完成马上又发起一批新的请求。

所以我们应该从等待下游反馈请求分配尽量均匀两个角度去做并发控制,当然实际项目中应该是两方面结合才行。

本文的可执行示例代码请访问下面的链接查看:

https://github.com/kevinyan815/gocookbook/blob/master/codes/prevent_over_concurrency/main.go

使用限流器

我们在向下游发起并发请求时可以通过限流器做一下限流,如果达到限制就阻塞直到能再次发起请求。一听到阻塞直到blabla 有的同学是不是马上内心小激动想用 channel 去实现一个限流器啦,「此处应用咳嗽声」其实完全没必要Golang 官方限流器 time/rate包的 Wait 方法就能给我们提供了这个功能。

func useRateLimit() {
 limiter := rate.NewLimiter(rate.Every(1*time.Second), 500)
 batchSize := 500
 for {
  data, _ :=queryDataWithSizeN(batchSize)
  if len(data) == 0 {
   fmt.Println("End of all data")
   break
  }

  for _, item := range data {
   // 阻塞直到令牌桶有充足的Token
   err := limiter.Wait(context.Background())
   if err != nil {
    fmt.Println("Error: ", err)
    return
   }
   go func(i int) {
    doSomething(i)
   }(item)
  }
 }
}

// 模拟调用下游服务
func doSomething(i int) {
 time.Sleep(2 * time.Second)
 fmt.Println("End:", i)
}

// 模拟查询N条数据
func queryDataWithSizeN(size int) (dataList []int, err error) {
 rand.Seed(time.Now().Unix())
 dataList = rand.Perm(size)
 return
}

time/rate包提供的限流器采用的是令牌桶算法,使用Wait方法是当桶中没有足够的令牌时调用者会阻塞直到能取到令牌,当然也可以通过Wait方法接受的Context参数设置等待超时时间。限流器往桶中放令牌的速率是恒定的这样比单纯使用time.Sleep请求更均匀些。

关于time/rate 限流器的使用方法的详解,请查看我之前的文章:Golang官方限流器的用法详解

用了限流器了之后,只是让我们的并发请求分布地更均匀了,最好我们能在受到下游反馈完成后再开始下次并发。

使用WaitGroup

我们可以等上批并发请求都执行完后再开始下一批任务,估计大部分同学听到这马上就会想到应该加WaitGroup

WaitGroup适合用于并发-等待的场景:一个goroutine在检查点(Check Point)等待一组执行任务的 worker goroutine 全部完成,如果在执行任务的这些worker goroutine 还没全部完成,等待的 goroutine 就会阻塞在检查点,直到所有woker goroutine 都完成后才能继续执行。

func useWaitGroup() {

 batchSize := 500
 for {
  data, _ := queryDataWithSizeN(batchSize)
  if len(data) == 0 {
   fmt.Println("End of all data")
   break
  }
  var wg sync.WaitGroup
  for _, item := range data {
   wg.Add(1)
   go func(i int) {
    doSomething(i)
    wg.Done()
   }(item)
  }
  wg.Wait()

  fmt.Println("Next bunch of data")
 }
}

这里调用程序会等待这一批任务都执行完后,再开始查下一批数据进行下一批请求,等待时间取决于这一批请求中最晚返回的那个响应用了多少时间。

使用Semaphore

如果你不想等一批全部完成后再开始下一批,也可以采用一个完成后下一个补上的策略,这种比使用WaitGroup做并发控制,如果下游资源够,整个任务的处理时间会更快一些。这种策略需要使用信号量(Semaphore)做并发控制,Go 语言里通过扩展库golang.org/x/sync/semaphore 提供了信号量并发原语。

关于信号量的使用方法和实现原理,可以读读我以前的文章:并发编程-信号量的使用方法和其实现原理

上面的程序改为使用信号量semaphore.Weighted做并发控制的示例如下:

func useSemaphore() {
 var concurrentNum int64 = 10
 var weight int64 = 1
 var batchSize int = 50
 s := semaphore.NewWeighted(concurrentNum)
 for {
  data, _ := queryDataWithSizeN(batchSize)
  if len(data) == 0 {
   fmt.Println("End of all data")
   break
  }

  for _, item := range data {
      s.Acquire(context.Background(), weight)
   go func(i int) {
    doSomething(i)
    s.Release(weight)
   }(item)
  }

 }
}

使用生产者消费者模式

也有不少读者回复说得加线程池才行,因为每个人公司里可能都有在用的线程池实现,直接用就行,我在这里就不再献丑给大家实现线程池了。在我看来我们其实是需要实现一个生产者和消费者模式,让线程池帮助我们限制只有固定数量的消费者线程去做下游服务的调用,而生产者则是将数据存储里取出来。

channel 正好能够作为两者之间的媒介。

func useChannel() {
 batchSize := 50
 dataChan := make(chan int)
 var wg sync.WaitGroup
 wg.Add(batchSize + 1)
 // 生产者
 go func() {
  for {
   data, _ := queryDataWithSizeN(batchSize)
   if len(data) == 0 {
    break
   }
   for _, item := range data {
    dataChan <- item
   }
  }
  close(dataChan)
  wg.Done()
 }()
    // 消费者
 go func() {
  for i := 0; i < 50; i++ {
   go func() {
    for {
     select {
     case v, ok := <- dataChan:
      if !ok {
       wg.Done()
       return
      }
      doSomething(v)
     }
    }
   }()
  }
 }()

 wg.Wait()
}

这个代码实现里,如果用ErrorGroup代替WaitGroup的话还能更简化一些,这个就留给读者自己探索吧。

关于ErrorGroup的用法总结,推荐阅读文章:觉得WaitGroup不好用?试试ErrorGroup吧!

总结

通过文章里总结的一些方法,我们也能看出来并发编程的场景下,除了关注发起的并发线程数外,更重要的是还需要关注被异步调用的下层服务的反馈,不是一味的加并发数就能解决问题的。理解我们为什么在并发编程中要关注下层服务的反馈是很重要的,否则我们列举的那些方案其实都可以在goroutine里再开goroutine,不关心是否执行完成直接返回,无限套娃下去。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 对于这个问题,我可以回答。可以采用负载均衡的方式,将10000个客户端的请求分配到100个服务器上,以达到并发访问的目的。具体实现方式可以使用软件负载均衡器,如Nginx、HAProxy等,也可以使用硬件负载均衡器,如F5等。同时,还需要对服务器进行优化,如增加带宽、调整内核参数等,以提高服务器的并发处理能力。 ### 回答2: 要实现10000个客户端并发访问100个服务器,可以采取以下几种方法: 1. 负载均衡:使用负载均衡技术,将10000个客户端的请求分散到100个服务器上,确保每个服务器的负载均衡。可以采用硬件负载均衡器,如F5等,或者软件负载均衡器,如Nginx等,来实现请求的分发和负载均衡。 2. 集群:建立一个服务器集群,将100个服务器组织成一个逻辑集群,每个客户端的请求都可以发送到集群中的一台服务器,然后由集群内部的负载均衡机制将请求分发给空闲的服务器。集群可以提高服务器的可扩展性和容错性,确保并发访问的高性能和高可用性。 3. 分布式计算:将业务逻辑拆分成多个独立的服务,每个服务运行在一个服务器上,客户端的请求可以同时发送到多个服务上,并行处理。分布式计算可以提高整个系统的处理能力和并发性能,同时也可以提高系统的可扩展性。 4. 缓存:使用缓存技术,将一些常用的数据缓存在服务器或客户端上,减少对服务器的访问压力。通过合理地使用缓存,可以降低服务器的并发负载,提高整个系统的性能。 通过上述方法的组合使用,可以实现10000个客户端并发访问100个服务器的需求,确保系统具有较高的性能和可扩展性。 ### 回答3: 要实现10000个客户端并发访问100个服务器,需要采取一些解决方案来实现高效的负载均衡和并发处理。 首先,可以使用负载均衡器来分发客户端的请求。负载均衡器可以根据一定的规则,例如轮询、加权轮询、最少连接等,将客户端请求均匀地分发给100个服务器。这将保证每个服务器都能够得到处理,并减轻服务器的负载。 其次,可以采用并发处理的技术来提高服务器的处理能力。对于每个服务器,可以使用多线程或多进程的方式处理来自客户端的请求。这样可以同时处理多个请求,提高服务器的并发处理能力。 另外,还可以使用缓存技术来提高系统的响应速度和并发访问能力。对于一些频繁请求的数据或计算结果,可以将其缓存在服务器端,以减少重复计算和查询数据库的次数,进而提高整个系统的并发处理能力。 最后,要进行性能测试和调优。在实际部署之前,需要对服务器和网络进行性能测试,以确定系统的瓶颈所在,并进行相应的调优和优化。这样可以保证系统在实际并发访问情况下的稳定性和性能。 综上所述,要实现10000个客户端并发访问100个服务器,需要使用负载均衡器、并发处理技术、缓存技术以及性能测试与调优等措施来实现高效的分发和处理。这样可以确保系统能够满足大规模并发访问的需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值