singleflight в Go: как не делать один и тот же запрос 100 ра
Представим HTTP-сервис. Одновременно приходит 100 запросов, и всем нужен один и тот же ресурс: request 1 ─┐ request 2 ─┤ request 3 ─┤ ... ├──> external API request 100┘ Каждый запрос запускает один и тот же дорогой вызов. В итоге внешний сервис получает 100 одинаковых запросов. Это один из вариантов проблемы, которую называют thundering herd. Тут помогает singleflight В Go есть пакет: golang.org/x/sync/singleflight Пример: var group singleflight.Group
func getUser(id string) (User, error) { value, err, _ := group.Do(id, func() (any, error) { return loadUserFromAPI(id) })
if err != nil { return User{}, err }
return value.(User), nil } Третий результат Do называется shared. Он показывает, использовали ли результат несколько одновременно ожидающих callers. В большинстве случаев он вообще не нужен, поэтому его можно просто игнорировать через _. Если одновременно 100 goroutines вызовут: getUser("123") то loadUserFromAPI("123") выполнится один раз. ┌──> actual API call │ 100 requests ┤ │ ├──> wait ├──> wait ├──> wait └──> wait Один вызов делает работу, остальные ждут его результат. Но есть важное ограничение singleflight объединяет только перекрывающиеся по времени вызовы. Это не означает: "Для этого key не делать запрос несколько секунд". Например: request A ────────┐ └── API call
request B ────────┐ └── получает результат A
request C ────────┐ └── новый API call Если request C пришёл уже после завершения первого вызова, функция выполнится снова. Поэтому singleflight - это не cache. А что происходит с ошибками? Допустим, внутри loadUserFromAPI произошла временная ошибка: func loadUserFromAPI(id string) (User, error) { // ...
return User{}, errors.New("API unavailable") } Все callers, которые ждали этот вызов, получат ту же ошибку. При этом после завершения вызова key больше не блокирует новые вызовы. group.Forget(key) нужен для другого сценария - когда нужно забыть key ещё во время выполнения текущего вызова, чтобы следующий caller мог начать новую работу. Например, если во время выполнения стало известно, что текущий результат больше не актуален. А что с context? Здесь есть важный момент. Обычный: group.Do(key, fn) не принимает context.Context. Если HTTP-клиент отключился, ожидающая goroutine сама по себе не перестанет ждать результат Do. Для более сложных сценариев есть: group.DoChan(key, fn) Он возвращает канал с результатом. Например: resultCh := group.DoChan(key, func() (any, error) { return loadUserFromAPI(ctx, id) })
select { case result := <-resultCh: // получили результат case <-ctx.Done(): // клиент больше не ждёт return User{}, ctx.Err() } Здесь отменяется ожидание конкретного caller. Но сама функция внутри singleflight автоматически не отменяется. Если нужно отменять и её, loadUserFromAPI должен сам поддерживать context.Context. Ещё один нюанс: канал, который возвращает DoChan, буферизован. Поэтому если caller ушёл по ctx.Done(), завершившийся вызов всё равно сможет положить результат в канал и не зависнет из-за того, что его больше никто не читает. Где это особенно полезно? Например, вместе с cache: cache ↓ miss singleflight ↓ external API ↓ cache При cache miss одновременно может прийти много одинаковых запросов. singleflight объединит их в один выполняющийся вызов. Но если одновременно запрашиваются разные keys: user:1 user:2 user:3 ... user:10000 singleflight не превращается автоматически в Worker Pool и не ограничивает общее количество выполняющихся запросов. В итоге у singleflight очень конкретная задача: не выполнять одну и ту же работу несколько раз, когда несколько callers одновременно ждут один и тот же результат. А уже: cache отвечает за хранение результата; singleflight объединяет одинаковую одновременно выполняющуюся работу; Worker Pool ограничивает количество одновременно выполняем