Как разработчик программного обеспечения, вам постоянно приходится иметь дело с базами данных, сторонними приложениями и другими службами. И иногда, если разрабатываемое вами приложение предполагает большую активность, вам придется дважды подумать об архитектуре вашего приложения, потому что каждое приложение не будет вести себя одинаково при масштабировании. Поэтому весьма вероятно, что вы окажетесь в ситуации, когда вам захочется уменьшить количество соединений, которые вы открываете со своей базой данных, сократить количество раз, когда вы звоните в эту дорогостоящую стороннюю службу и т. д. Я был в такой же ситуации. Итак, я расскажу вам о решении, которое я нашел, которое будет полезно в таких сценариях.

Прежде всего, давайте рассмотрим простой веб-сервер go.

Пример 1

package main
import (
   "fmt"
   "io"
   "log"
   "net/http"
   "time"
)
var cost int64
func main() {
    http.HandleFunc("/fetch_data", GetDataHandler)
    log.Println("Serving Port :  8000")
    http.ListenAndServe(":8000", nil)
}

func GetDataHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Println(r.URL)
    data, _ := GetData()
    io.WriteString(w, data)
}
func GetData() (string, error) {
    time.Sleep(5 * time.Second)
    cost += 100
    fmt.Printf("Cost : %d \n", cost)
    return fmt.Sprintf("Fetch called : %d", cost), nil 
}

Этот сервер имеет конечную точку с именем /fetch_data, которая инициирует вызов функции GetData(). Предположим, что эта функция GetData() представляет вызов базы данных или сторонний вызов, который мы хотели бы сделать. В этой функции GetData() я сделал ее спящей на 5 секунд, чтобы сделать вызов более реалистичным. Также каждый раз, когда вызывается эта функция, стоимость глобальной переменной увеличивается на 100. Это показывает, что каждый вызов GetData() стоит нам 100 долларов.

Чтобы проверить, как наш сервер справляется с интенсивным трафиком, я буду использовать инструмент нагрузочного тестирования (который также написан на Go) под названием Vegeta (https://github.com/tsenart/vegeta). В качестве нашего первого теста я буду вызывать конечную точку /fetch_data в течение 5 секунд со скоростью 10 запросов в секунду. Теперь запустим тест.

vegeta attack -targets=targets_normal.txt -duration=5s -rate=10 | tee results_normal.bin | vegeta report

target_normal.txt содержит тестируемые методы REST и URL-адреса.

GET http://localhost:8000/fetch_data

Теперь давайте посмотрим на результаты.

Requests      [total, rate]            50, 10.20
Duration      [total, attack, wait]    9.909676807s, 4.903610868s, 5.006065939s
Latencies     [mean, 50, 95, 99, max]  5.005698559s, 5.006212501s, 5.008185721s, 5.013624665s, 5.013624665s
Bytes In      [total, mean]            941, 18.82
Bytes Out     [total, mean]            0, 0.00
Success       [ratio]                  100.00%
Status Codes  [code:count]             200:50
Error Set:

результаты показывают, что выполнено 50 успешных запросов. И если мы посмотрим на журналы сервера, мы увидим, что общая стоимость этих 50 запросов составила 5000 долларов США. Текущий дизайн приложения вызывает GetData() каждый раз, когда приходит запрос, даже если все запросы абсолютно одинаковы. Что, если мы сможем один раз выполнить функцию GetData() и удовлетворить все запросы?

Вот тут-то и пригодится пакет sync/singleflight.

Пример 2

package main
import (
"fmt"
"io"
"log"
"net/http"
"time"
"golang.org/x/sync/singleflight"
)
var cost int64
func main() {
   var requestGroup singleflight.Group
   http.HandleFunc("/fetch_data", RequestGroup{r:     &requestGroup}.GetDataHandler)
   log.Println("Serving Port :  8001")
   http.ListenAndServe(":8001", nil)
}
type RequestGroup struct {
    r *singleflight.Group
}
func (rg RequestGroup) GetDataHandler(w http.ResponseWriter, r *http.Request) {
   fmt.Println(r.URL)
   value, err, _ := rg.r.Do(r.URL.String(), func() (interface{}, error) {
       go func() {
          time.Sleep(5000 * time.Millisecond)
          rg.r.Forget(r.URL.String())
       }()
      return GetData()
   })
   if err != nil {
   http.Error(w, err.Error(), http.StatusInternalServerError)
   return
}
   data := value.(string)
   io.WriteString(w, data)
}
func GetData() (string, error) {
   time.Sleep(5 * time.Second)
   cost += 100
   fmt.Printf("Cost : %d \n", cost)
   return fmt.Sprintf("Fetch called : %d", cost), nil
}

Мы будем использовать функцию Do() в пакете singleflight. Функция Do() принимает строку в качестве ключа и функцию, которая возвращает ошибку, и интерфейс в качестве входных данных. Singleflight гарантирует, что для данного ключа произойдет только одно выполнение переданной функции. Что касается ключа, я буду использовать URL-адрес запроса. В данном случае это будет /fetch_data. В функции Do() мы запустим процедуру go в фоновом режиме, а затем попросим singleflight забыть запрос через 5 секунд. Это означает, что любой запрос, который поступает в течение 5 секунд после поступления первого запроса, будет обслужен с тем же ответом от функции GetData().

Теперь запустим тот же нагрузочный тест и посмотрим, как поведет себя приложение.

vegeta attack -targets=targets_singleflight.txt -duration=5s -rate=10 | tee results_singleflight.bin | vegeta report

Давайте посмотрим на результаты.

Requests      [total, rate]            50, 10.20
Duration      [total, attack, wait]    5.014053656s, 4.900833893s, 113.219763ms
Latencies     [mean, 50, 95, 99, max]  2.561878061s, 2.560777702s, 4.8108588s, 5.013957585s, 5.013957585s
Bytes In      [total, mean]            900, 18.00
Bytes Out     [total, mean]            0, 0.00
Success       [ratio]                  100.00%
Status Codes  [code:count]             200:50
Error Set:

Все выглядит очень похоже (хотя тест на сервере с singleflight занял меньше времени для обработки 100 запросов. И это хорошо :) ) по сравнению с предыдущим тестом. Но когда мы проверяем журналы сервера, мы видим, что по сравнению с 5000 долларов США на этот раз мы потратили только 100 долларов США. Это довольно значительная экономия средств. Давайте запустим нагрузочный тест на 10 секунд для обоих приложений.

---- Starting attack -----
---- Attacking Datasource Server ----
Requests      [total, rate]            100, 10.10
Duration      [total, attack, wait]    14.904334343s, 9.902888604s, 5.001445739s
Latencies     [mean, 50, 95, 99, max]  5.00261956s, 5.002215223s, 5.00566831s, 5.009361413s, 5.012719579s
Bytes In      [total, mean]            1892, 18.92
Bytes Out     [total, mean]            0, 0.00
Success       [ratio]                  100.00%
Status Codes  [code:count]             200:100
Error Set:
---- Attacking Datasource with singleflight ----
Requests      [total, rate]            100, 10.10
Duration      [total, attack, wait]    10.106814205s, 9.903963803s, 202.850402ms
Latencies     [mean, 50, 95, 99, max]  2.555588866s, 2.556200382s, 4.807365963s, 5.008225608s, 5.014383781s
Bytes In      [total, mean]            1800, 18.00
Bytes Out     [total, mean]            0, 0.00
Success       [ratio]                  100.00%
Status Codes  [code:count]             200:100
Error Set:
---- Ending  attack -----

В этом случае приложение без одиночной проверки обошлось нам в 10 000 долл. США, тогда как другое приложение с однократной отправкой обошлось всего в 200 долларов США.

Последние мысли ..

Я был очень удивлен, обнаружив, что примеров/статей об этом пакете всего несколько. Надеюсь, вы найдете эту статью полезной в своих ежедневных приключениях по решению проблем.

Весь код для этой статьи находится в этом репозитории.