Каждый бэкенд-инженер хотя бы раз в карьере переживал эту драму: высоконагруженный сервис спокойно отдаёт десятки тысяч запросов в секунду из быстрого Redis-кэша, графики времени ответа ровные, мониторинг зелёный. И тут наступает момент инвалидации популярного ключа — например, карточки горячего товара на распродаже или профиля знаменитости.
В одну миллисекунду сотни параллельных горутин обращаются в кэш, получают ответ «данных нет» (cache miss) и дружно ломятся в основную базу данных с абсолютно одинаковым тяжёлым SQL-запросом. База захлёбывается в пуле соединений, время ответа взлетает до небес, срабатывают каскадные таймауты, и прод падает. В индустрии у этого кошмара есть звучные имена: Cache Stampede («лавина кэша») или Thundering Herd («эффект ревущего стада»).
Первая интуитивная реакция разработчика — нагородить вокруг распределённые блокировки через Redis Redlock или навесить глобальный мьютекс. Но распределённый лок добавляет лишние сетевые задержки и может зависнуть при падении ноды, а наивный sync.Mutex намертво стопорит все остальные операции процесса.
В стандартном репозитории вспомогательных пакетов Go есть потрясающе лаконичное и надёжное решение этой проблемы — пакет singleflight из набора golang.org/x/sync.
Анатомия паники: как один протухший ключ укладывает базу данных
Давай разберём физику процесса. Представь супермаркет в преддверии Нового года. На полке закончилось шампанское. Если каждый из сотни покупателей лично побежит в складскую подсобку к одному кладовщику с криком «Принеси мне бутылку!», на складе начнётся давка, и работа встанет.
Разумный выход: первый подошедший говорит консультанту: «Нам нужно шампанское». Консультант просит остальных девяносто девять человек подождать у полки пару минут, сам идёт на склад, выносит ящик и раздаёт бутылки всем ожидающим. В подсобку был сделан ровно один поход, а удовлетворены запросы сотни покупателей.
Именно это делает структура singleflight.Group. Она подавляет дубликаты конкурентных вызовов функции в оперативной памяти одного процесса приложения, объединяя их в один реальный запрос.
Как устроен Group.Do и почему это не кэш, а барьер синхронизации
Базовый интерфейс типа singleflight.Group минималистичен:
func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool)
Механика работы проста:
- Первая горутина вызывает
g.Do("product:42", fetchFromDB). Группа видит, что для строкового ключа"product:42"активных задач нет. Она регистрирует внутреннюю структуру вызоваcall, захватывает локальную блокировку и запускаетfn(). - В то же самое мгновение ещё 50 параллельных горутин вызывают
g.Doс тем же самым ключом"product:42". Группа видит активный вызов, не запускаетfnповторно, а блокирует эти горутины через примитив синхронизацииsync.WaitGroup. - Когда первая функция завершает работу и возвращает результат, все 51 горутина одновременно разблокируются и получают одинаковое значение
v, ошибкуerrи флагshared = true(указывающий, что результат был разделён между несколькими потребителями). - Ключ моментально удаляется из внутренней мапы группы.
Обрати внимание: singleflight принципиально не является кэшем. Он ничего не хранит дольше, чем длится выполнение самого запроса. Если следующий запрос придёт через микросекунду после того, как группа завершила работу, функция запустится снова. singleflight защищает систему только от одновременных дублей в момент промаха.
Защита от зависаний: связываем DoChan с context.Context
Классический метод Group.Do блокирует вызывающую горутину до победного конца. А что, если база данных зависла или внешний сервис перестал отвечать? Все ожидающие горутины окажутся в заложниках, клиентские HTTP-соединения повиснут, и приложение исчерпает лимиты памяти.
Чтобы этого избежать, в пакете предусмотрен неблокирующий метод DoChan, возвращающий однонаправленный канал <-chan Result. В связке с контекстом context.Context он даёт абсолютную защиту от зависаний:
package repository
import (
"context"
"database/sql"
"fmt"
"golang.org/x/sync/singleflight"
)
type Product struct {
ID int64
Title string
Price int64
}
type ProductStore struct {
db *sql.DB
group singleflight.Group
}
func (s *ProductStore) GetProduct(ctx context.Context, tenantID int, productID int64) (*Product, error) {
// Ключ дедупликации обязан учитывать границы тенанта и прав
key := fmt.Sprintf("tenant:%d:product:%d", tenantID, productID)
ch := s.group.DoChan(key, func() (any, error) {
var p Product
query := `SELECT id, title, price FROM products WHERE tenant_id = $1 AND id = $2`
// Внутрь фонового запроса передаем независимый контекст,
// чтобы отмена одного клиента не сорвала выполнение для остальных
err := s.db.QueryRowContext(context.Background(), query, tenantID, productID).
Scan(&p.ID, &p.Title, &p.Price)
if err != nil {
return nil, err
}
return &p, nil
})
select {
case <-ctx.Done():
// Клиент отменил запрос или вышел по HTTP-таймауту
return nil, ctx.Err()
case res := <-ch:
if res.Err != nil {
return nil, res.Err
}
// Защита от гонок памяти: отдаем копию структуры
orig := res.Val.(*Product)
copyItem := *orig
return ©Item, nil
}
}
Обрати внимание на важную тонкость: внутрь замыкания fn передаётся фоновый контекст context.Background(). Если передать туда клиентский ctx первого запроса, а этот клиент оборвёт соединение, отмена контекста убьёт запрос для всех остальных горутин, которые всё ещё ждут результат!
Типичные ловушки: гонки указателей и время жизни группы
При использовании singleflight неопытные инженеры чаще всего наступают на две архитектурные грабли:
- Гонки по мутабельным ссылкам (Data Races):
Если твоя функция возвращает указатель на структуру (
*Product) или слайс, то все 50 горутин получат ссылку на один и тот же участок памяти в куче. Если кто-то из вызывающих позже решит изменить поле (например,product.Price = 0), программа получит классическую гонку данных. Решение: возвращай значения по значению либо делай поверхностную копию (clone = *orig) перед возвратом клиенту, как показано в примере выше. - Время жизни экземпляра группы:
Экземпляр
singleflight.Groupобязан жить на уровне долгоживущего сервиса или репозитория. Объявлениеvar g singleflight.Groupвнутри тела HTTP-хендлера полностью обесценивает паттерн: каждый HTTP-запрос создаст собственную пустую группу, и никакой дедупликации не произойдёт.
Внедрение singleflight перед обращением к базе при промахах кэша занимает буквально двадцать строчек кода, но спасает прод от самых неприятных ночных аварий под пиковыми нагрузками.
