Разбор задач с собеседования по Go
Сегодня мы разберём реальное техническое собеседование на позицию Go-разработчика: от задач на горутины и каналы до написания потокобезопасного кэша с обсуждением мьютексов, атомиков и тонкостей многопоточности. Мы увидим, как кандиент рассуждает вслух, допускает ошибки, задаёт вопросы и постепенно приходит к рабочему решению — а также разберём типичные ловушки, которые встречаются на интервью. В завершение обсудим структуру подготовки к собеседованиям, роль менторства и как правильно оценивать свой уровень для перехода на новый грейд.
Вопрос 1. Что выведет данный код на Go и почему? Задача change pointers на смену указателей.
Таймкод: 00:08:24
Ответ собеседника: неполный. В коде есть структура Person с полем name и функция, которая должна менять имя. Из-за особенностей передачи указателей в Go, указатель передается по значению, создается новый объект и присваивается локальной копии указателя. Ожидаемый вывод - два раза 'Боб', так как оригинальный объект не изменяется. Для исправления нужно изменять структуру, на которую указывает указатель, а не сам указатель.
Правильный ответ:
Рассмотрим типичный пример этой задачи:
package main
import "fmt"
type Person struct {
name string
}
func changeName(p *Person) {
p = &Person{name: "Alice"}
}
func main() {
bob := &Person{name: "Bob"}
fmt.Println(bob.name) // Bob
changeName(bob)
fmt.Println(bob.name) // Bob
}
Вывод программы:
Bob
Bob
Почему так происходит:
В Go всё передаётся по значению — это ключевой принцип, который нужно понимать глубоко. Когда мы передаём указатель в функцию, копируется сам указатель (то есть адрес в памяти), а не объект, на который он указывает.
Внутри функции changeName параметр p — это локальная копия указателя. Строка p = &Person{name: "Alice"} перенаправляет эту локальную копию на совершенно новый объект в памяти. Оригинальный указатель bob в функции main при этом никак не затрагивается — он по-прежнему указывает на объект {name: "Bob"}.
Визуализация памяти:
До вызова changeName:
bob (в main) ──→ {name: "Bob"} (адрес 0x1000)
Внутри changeName:
p (копия bob) ──→ {name: "Bob"} (адрес 0x1000)
p = &Person{...} перенаправляет p на:
p (копия bob) ──→ {name: "Alice"} (адрес 0x2000) ← новый объект
bob (в main) ──→ {name: "Bob"} (адрес 0x1000) ← не изменился!
Как правильно изменить имя — три подхода:
1. Изменить поле объекта, на который указывает указатель (разыменование):
func changeName(p *Person) {
p.name = "Alice" // меняем поле объекта по адресу, а не сам указатель
}
func main() {
bob := &Person{name: "Bob"}
fmt.Println(bob.name) // Bob
changeName(bob)
fmt.Println(bob.name) // Alice ✓
}
2. Использовать указатель на указатель (двойной указатель):
func changeName(p **Person) {
*p = &Person{name: "Alice"} // меняем оригинальный указатель через двойное разыменование
}
func main() {
bob := &Person{name: "Bob"}
fmt.Println(bob.name) // Bob
changeName(&bob) // передаём адрес указателя
fmt.Println(bob.name) // Alice ✓
}
3. Вернуть новый указатель и присвоить его:
func changeName(p *Person) *Person {
return &Person{name: "Alice"}
}
func main() {
bob := &Person{name: "Bob"}
fmt.Println(bob.name) // Bob
bob = changeName(bob) // переприсваиваем оригинальный указатель
fmt.Println(bob.name) // Alice ✓
}
Ключевые выводы для интервью:
- Указатель в Go — это значение (адрес в памяти), которое копируется при передаче в функцию
- Перенаправление копии указателя (
p = &NewValue) не влияет на оригинал - Изменение данных по адресу (
p.field = value) видно вызывающей стороне - Для замены самого указателя нужен либо двойной указатель, либо возврат нового значения
- Первый подход (изменение полей) — самый идиоматичный и предпочтительный в Go
Вопрос 2. Что выведет данный код с горутинами и замыканием? Задача на примитивы синхронизации и concurrency.
Таймкод: 00:11:39
Ответ собеседника: неполный. В коде запускается 1000 горутин в цикле, каждая вычисляет максимум из переменной i. Проблема с захватом переменной в замыкании. В Go до 1.22 переменная цикла имела фиксированный адрес памяти, все горутины работали с одной переменной. В Go 1.22+ каждая итерация создает новую переменную. Отсутствует синхронизация (WaitGroup), что приводит к непредсказуемому выводу. При запуске выводятся случайные значения (0, 989, 973). Для исправления нужно: использовать WaitGroup, правильно передавать переменную в замыкание, использовать мьютекс для синхронизации доступа к переменной maximum, так как операции чтения и записи не атомарны.
Правильный ответ:
Рассмотрим типичный пример этой задачи:
package main
import (
"fmt"
"sync"
)
func main() {
var maximum int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if i > maximum {
maximum = i
}
}()
}
wg.Wait()
fmt.Println(maximum)
}
Проблемы в этом коде — разберём каждую:
1. Захват переменной цикла замыканием (loop variable capture)
Это одна из самых коварных ловушек в Go. Замыкание захватывает переменную i по ссылке, а не по значению.
-
В Go до версии 1.22: переменная
iимела единственный адрес памяти на все итерации. К тому моменту, когда горутины начнут выполняться, цикл уже завершится иiбудет равно 1000 (или горутины прочитают промежуточное значение — это гонка данных). Все горутины видят одно и то же значениеi. -
В Go 1.22+ каждая итерация цикла
forсоздаёт новую переменную, что устраняет эту конкретную проблему. Однако остальные проблемы остаются.
2. Отсутствие WaitGroup в оригинальном коде
Если в коде нет wg.Wait(), функция main завершится раньше, чем горутины успеют выполниться. В результате maximum может быть ещё равно 0, либо будут обработаны только некоторые горутины.
3. Гонка данных (data race) на переменной maximum
Даже если добавить WaitGroup, операция if i > maximum { maximum = i } не атомарна. Она состоит из трёх шагов:
- Чтение
maximumиз памяти - Сравнение с
i - Записи нового значения в
maximum
Две горутины могут одновременно прочитать одно и то же значение maximum, обе решить, что их i больше, и обе записать результат — но одна из записей перезапишет другую. Это классическая гонка данных.
Что выведет код:
Результат непредсказуем и зависит от:
- версии Go (до или после 1.22)
- наличия
WaitGroup - планировщика горутин и количества ядер CPU
Возможные варианты вывода: 0 (горутины не успели выполниться), любое значение от 0 до 999 (гонка данных).
Исправленная версия:
package main
import (
"fmt"
"sync"
)
func main() {
var maximum int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(val int) { // передаём значение через параметр
defer wg.Done()
mu.Lock() // защищаем доступ к maximum
if val > maximum {
maximum = val
}
mu.Unlock()
}(i) // передаём текущее значение i как аргумент
}
wg.Wait()
fmt.Println(maximum) // гарантированно 999
}
Альтернативные подходы к исправлению:
Использование sync/atomic для lock-free максимума:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var maximum int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(val int64) {
defer wg.Done()
for {
old := atomic.LoadInt64(&maximum)
if val <= old || atomic.CompareAndSwapInt64(&maximum, old, val) {
break
}
}
}(int64(i))
}
wg.Wait()
fmt.Println(maximum) // 999
}
Использование каналов (идиоматичный подход Go):
package main
import "fmt"
func main() {
results := make(chan int, 1000)
for i := 0; i < 1000; i++ {
go func(val int) {
results <- val
}(i)
}
maximum := 0
for i := 0; i < 1000; i++ {
val := <-results
if val > maximum {
maximum = val
}
}
fmt.Println(maximum) // 999
}
Ключевые выводы для интервью:
- Замыкания захватывают переменные по ссылке — всегда передавайте значение через параметр функции
- В Go 1.22+ переменные цикла создаются заново на каждой итерации, но это не решает проблему гонки данных
- Любой общей изменяемой переменной нужна синхронизация:
sync.Mutex,sync/atomicили каналы - Чтение и запись переменной — не атомарная операция, даже для
intна 64-битных системах go run -race— ваш лучший друг для обнаружения гонок данных
Вопрос 3. Как реализовать функцию объединения нескольких каналов в один (merge channels)?
Таймкод: 00:25:27
Ответ собеседника: неполный. Для объединения каналов нужно создать выходной канал, запустить горутину для каждого входного канала, которая будет читать значения и писать в выходной канал. Использовать WaitGroup для синхронизации горутин-писателей. Закрывать выходной канал после завершения всех писателей. Закрытие канала должен делать тот, кто пишет, так как только писатель знает, когда данные завершатся. Важно не забыть вызвать Wait перед закрытием канала.
Правильный ответ:
Объединение каналов (fan-in pattern) — один из фундаментальных паттернов конкурентного программирования в Go. Идея: несколько входных каналов сливаются в один выходной, из которого читатель получает все значения.
Базовая реализация с фиксированным числом каналов:
func merge(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for v := range ch1 {
out <- v
}
}()
go func() {
defer wg.Done()
for v := range ch2 {
out <- v
}
}()
go func() {
wg.Wait()
close(out)
}()
return out
}
Универсальная реализация для произвольного числа каналов:
func mergeChannels(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
// Запускаем читателя для каждого входного канала
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
// Закрываем выходной канал, когда все читатели завершатся
go func() {
wg.Wait()
close(out)
}()
return out
}
Важные нюансы реализации:
1. Закрытие канала — ответственность писателя
Закрытие канала должно происходить ровно один раз и только после того, как все писатели завершили работу. Если закрыть канал раньше — паника у ещё работающих писателей. Если не закрыть — читатель заблокируется навсегда в range.
2. Горутина-закрывающая — отдельная горутина
wg.Wait() блокирует вызывающую горутину. Если вызвать его в том же потоке, где создаётся out, произойдёт дедлок: горутина заблокируется на Wait, а читатель будет ждать данных из out, которые никогда не придут, потому что mergeChannels не вернёт управление.
3. Передача канала как параметра горутины
Обратите внимание на go func(c <-chan int) { ... }(ch) — канал передаётся как аргумент. Это предотвращает проблему захвата переменной цикла (актуально для Go < 1.22).
Пример использования:
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
ch3 := make(chan int)
go func() {
defer close(ch1)
for _, v := range []int{1, 2, 3} {
ch1 <- v
}
}()
go func() {
defer close(ch2)
for _, v := range []int{4, 5, 6} {
ch2 <- v
}
}()
go func() {
defer close(ch3)
for _, v := range []int{7, 8, 9} {
ch3 <- v
}
}()
merged := mergeChannels(ch1, ch2, ch3)
for v := range merged {
fmt.Println(v)
}
}
Продвинутая версия с контекстом для отмены:
func mergeWithContext(ctx context.Context, channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for {
select {
case v, ok := <-c:
if !ok {
return
}
select {
case out <- v:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Версия с буферизацией для повышения производительности:
func mergeBuffered(channels ...<-chan int) <-chan int {
// Буфер уменьшает блокировки между писателями и читателем
out := make(chan int, len(channels)*10)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Обобщённая версия с дженериками (Go 1.18+):
func Merge[T any](channels ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan T) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Ключевые выводы для интервью:
- Fan-in — стандартный паттерн для объединения нескольких источников данных в один поток
- Закрытие выходного канала должно происходить в отдельной горутине после
wg.Wait() - Каждый входной канал читается в своей горутине — это обеспечивает параллельное чтение
- Для production-кода стоит добавить поддержку
context.Contextдля корректной отмены - Буферизация выходного канала может значительно улучшить производительность при большом числе источников
Вопрос 4. Что выведет код с select из нескольких каналов и почему? Как сделать код параллельным?
Таймкод: 00:34:57
Ответ собеседника: неполный. Код с select из двух каналов выводит 6 секунд (последовательное ожидание), а не 3 секунды (параллельное ожидание). Это происходит потому что select с несколькими case блокируется до получения значения из первого доступного канала, затем ждет следующего. Для достижения параллельного выполнения за 3 секунды нужно использовать функцию JoinChannel (merge channels), которая объединяет каналы и позволяет читать из них параллельно.
Правильный ответ:
Рассмотрим типичный пример этой задачи:
package main
import (
"fmt"
"time"
)
func worker(name string, ch chan string, delay time.Duration) {
time.Sleep(delay)
ch <- fmt.Sprintf("%s done after %v", name, delay)
}
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go worker("A", ch1, 3*time.Second)
go worker("B", ch2, 3*time.Second)
// Вариант 1: последовательный select в цикле
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
fmt.Println("Total time:", time.Since(start))
}
Что выведет код и почему:
Вывод будет примерно таким:
A done after 3s (или B — порядок не определён)
B done after 3s (или A)
Total time: ~3s
В данном конкретном случае оба воркера завершаются за 3 секунды, и select получит первое значение через ~3 секунды, а второе — практически сразу после этого (поскольку оба воркера работают параллельно и завершаются одновременно).
Но есть нюанс — рассмотрим более показательный пример:
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go worker("A", ch1, 1*time.Second)
go worker("B", ch2, 5*time.Second)
// Последовательное чтение — НЕ параллельное
select {
case msg := <-ch1:
fmt.Println("First:", msg) // через 1 секунду
case msg := <-ch2:
fmt.Println("First:", msg) // через 5 секунд
}
select {
case msg := <-ch1:
fmt.Println("Second:", msg) // заблокируется, ch1 уже прочитан
case msg := <-ch2:
fmt.Println("Second:", msg) // ещё ждёт ~4 секунды
}
}
Здесь общее время составит ~6 секунд, хотя задачи выполняются параллельно. Проблема в том, что мы ждём результаты последовательно.
Как работает select — ключевые правила:
selectблокируется до тех пор, пока хотя бы одинcaseне будет готов- Если готовы несколько
caseодновременно — выбор случайный (равномерное распределение) selectвыполняет ровно одну операцию за раз (одинcase)- Для обработки нескольких каналов нужен цикл или отдельные горутины
Как сделать код по-настоящему параллельным:
Подход 1: Объединение каналов (merge/fan-in):
func merge(chs ...<-chan string) <-chan string {
out := make(chan string)
var wg sync.WaitGroup
for _, ch := range chs {
wg.Add(1)
go func(c <-chan string) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go worker("A", ch1, 3*time.Second)
go worker("B", ch2, 3*time.Second)
merged := merge(ch1, ch2)
for msg := range merged {
fmt.Println(msg)
}
// Общее время: ~3 секунды
}
Подход 2: Отдельные горутины-читатели с WaitGroup:
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go worker("A", ch1, 3*time.Second)
go worker("B", ch2, 3*time.Second)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
msg := <-ch1
fmt.Println(msg)
}()
go func() {
defer wg.Done()
msg := <-ch2
fmt.Println(msg)
}()
wg.Wait()
// Общее время: ~3 секунды
}
Подход 3: Использование sync.Once для закрытия канала:
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go worker("A", ch1, 3*time.Second)
go worker("B", ch2, 3*time.Second)
results := make(chan string, 2) // буферизованный
var once sync.Once
done := make(chan struct{})
go func() {
results <- <-ch1
once.Do(func() { close(done) })
}()
go func() {
results <- <-ch2
once.Do(func() { close(done) })
}()
<-done
// Ждём хотя бы один результат
}
Подход 4: Использование errgroup (golang.org/x/sync/errgroup):
import "golang.org/x/sync/errgroup"
func main() {
g, _ := errgroup.WithContext(context.Background())
results := make(chan string, 2)
g.Go(func() error {
// worker A
time.Sleep(3 * time.Second)
results <- "A done"
return nil
})
g.Go(func() error {
// worker B
time.Sleep(3 * time.Second)
results <- "B done"
return nil
})
go func() {
g.Wait()
close(results)
}()
for msg := range results {
fmt.Println(msg)
}
}
Ключевые выводы для интервью:
selectобрабатывает одинcaseза раз — это не параллельное ожидание нескольких результатов- Для параллельного ожидания нужно запустить отдельные горутины-читатели или использовать merge pattern
- Merge/fan-in — самый идиоматичный подход в Go для объединения результатов из нескольких каналов
- Порядок значений из разных каналов не гарантирован — зависит от планировщика
- Буферизация каналов помогает избежать блокировок при записи результатов
Вопрос 5. Как реализовать кэш с TTL (time to live) для функции с высокой нагрузкой (5k rps)?
Таймкод: 00:39:32
Ответ собеседника: неполный. Для реализации кэша с TTL нужно: создать структуру кэша с полями для значения, времени истечения TTL и RWMutex для синхронизации. RWMutex позволяет блокироваться только на чтение и делать полный лок при обновлении значения. Хранить результат функции на 1 секунду и отдавать его запросам. Раз в секунду обновлять значение через тикер. Важно уточнить требования: допустимо ли отдавать устаревший результат в течение секунды, какая нагрузка (RPS). Следовать практикам Go uber Style Guide для объявления мьютексов - использовать мьютекс как переменную, не встраивая в структуру. Определять интерфейс по месту использования для сужения скоупа. Для оптимизации можно использовать атомарные операции (atomic) вместо мьютексов, так как они работают через атомарные инструкции процессора без локов, что повышает производительность. Однако мьютексы более понятны и распространены.
Правильный ответ:
Кэш с TTL для высокой нагрузки — задача, требующая не просто правильной реализации, но и понимания стратегий обновления, конкурентного доступа и оптимизации под конкретный профиль нагрузки.
Уточняющие вопросы перед реализацией:
Прежде чем писать код, на интервью важно уточнить:
- Допустимо ли отдавать устаревшие данные на время обновления (stale-while-revalidate)?
- Какова стоимость вычисления значения — может ли несколько запросов параллельно вычислять одно и то же?
- Какова допустимая задержка при промахе кэша?
- Нужна ли инвалидация по событию или только по TTL?
Базовая реализация с RWMutex:
package cache
import (
"sync"
"time"
)
type entry struct {
value interface{}
expiresAt time.Time
}
type TTLCache struct {
mu sync.RWMutex
data map[string]entry
ttl time.Duration
compute func(key string) (interface{}, error)
}
func NewTTLCache(ttl time.Duration, compute func(key string) (interface{}, error)) *TTLCache {
return &TTLCache{
data: make(map[string]entry),
ttl: ttl,
compute: compute,
}
}
func (c *TTLCache) Get(key string) (interface{}, error) {
// Быстрый путь — чтение без блокировки записи
c.mu.RLock()
e, ok := c.data[key]
c.mu.RUnlock()
if ok && time.Now().Before(e.expiresAt) {
return e.value, nil
}
// Медленный путь — вычисление значения
c.mu.Lock()
defer c.mu.Unlock()
// Double-check: другая горутина могла обновить значение, пока мы ждали Lock
e, ok = c.data[key]
if ok && time.Now().Before(e.expiresAt) {
return e.value, nil
}
value, err := c.compute(key)
if err != nil {
return nil, err
}
c.data[key] = entry{
value: value,
expiresAt: time.Now().Add(c.ttl),
}
return value, nil
}
Оптимизация: предотвращение thundering herd
При 5k rps и истечении TTL все запросы одновременно попытаются вычислить значение. Это называется thundering herd — множество горутин дублируют одну и ту же дорогую операция.
package cache
import (
"sync"
"time"
)
type entry struct {
value interface{}
expiresAt time.Time
}
// singleflight предотвращает дублирование вычислений
type call struct {
wg sync.WaitGroup
val interface{}
err error
}
type TTLCache struct {
mu sync.RWMutex
data map[string]entry
inFlight map[string]*call // отслеживание текущих вычислений
ttl time.Duration
compute func(key string) (interface{}, error)
}
func NewTTLCache(ttl time.Duration, compute func(key string) (interface{}, error)) *TTLCache {
c := &TTLCache{
data: make(map[string]entry),
inFlight: make(map[string]*call),
ttl: ttl,
compute: compute,
}
// Фоновая очистка устаревших записей
go c.cleanup()
return c
}
func (c *TTLCache) Get(key string) (interface{}, error) {
// Быстрый путь
c.mu.RLock()
e, ok := c.data[key]
if ok && time.Now().Before(e.expiresAt) {
c.mu.RUnlock()
return e.value, nil
}
// Проверяем, есть ли уже текущее вычисление для этого ключа
if call, ok := c.inFlight[key]; ok {
c.mu.RUnlock()
call.wg.Wait() // ждём завершения уже идущего вычисления
return call.val, call.err
}
c.mu.RUnlock()
// Медленный путь — становимся лидером вычисления
c.mu.Lock()
// Double-check после получения блокировки
e, ok = c.data[key]
if ok && time.Now().Before(e.expiresAt) {
c.mu.Unlock()
return e.value, nil
}
// Проверяем in-flight ещё раз
if call, ok := c.inFlight[key]; ok {
c.mu.Unlock()
call.wg.Wait()
return call.val, call.err
}
// Регистрируем себя как вычисляющего
call := &call{}
call.wg.Add(1)
c.inFlight[key] = call
c.mu.Unlock()
// Вычисляем значение (без блокировки!)
call.val, call.err = c.compute(key)
call.wg.Done()
// Сохраняем результат
c.mu.Lock()
delete(c.inFlight, key)
if call.err == nil {
c.data[key] = entry{
value: call.val,
expiresAt: time.Now().Add(c.ttl),
}
}
c.mu.Unlock()
return call.val, call.err
}
func (c *TTLCache) cleanup() {
ticker := time.NewTicker(c.ttl)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for k, v := range c.data {
if now.After(v.expiresAt) {
delete(c.data, k)
}
}
c.mu.Unlock()
}
}
Оптимизация: stale-while-revalidate
Для 5k rps критически важно, чтобы запросы никогда не блокировались на вычислении. Паттерн stale-while-revalidate позволяет отдавать устаревшее значение, пока новое вычисляется в фоне:
type entry struct {
value interface{}
expiresAt time.Time
staleAt time.Time // момент, после которого начинаем фоновое обновление
}
type TTLCache struct {
mu sync.RWMutex
data map[string]entry
ttl time.Duration
staleTTL time.Duration // дополнительное время для stale-while-revalidate
compute func(key string) (interface{}, error)
}
func (c *TTLCache) Get(key string) (interface{}, error) {
c.mu.RLock()
e, ok := c.data[key]
c.mu.RUnlock()
now := time.Now()
if ok && now.Before(e.expiresAt) {
// Значение свежее — отдаём сразу
return e.value, nil
}
if ok && now.Before(e.staleAt) {
// Значение устаревшее, но ещё допустимое — отдаём и обновляем в фоне
c.mu.Lock()
if call, inFlight := c.inFlight[key]; inFlight {
c.mu.Unlock()
return e.value, nil // уже обновляется, отдаём stale
}
call := &call{}
call.wg.Add(1)
c.inFlight[key] = call
c.mu.Unlock()
// Фоновое обновление
go func() {
val, err := c.compute(key)
c.mu.Lock()
delete(c.inFlight, key)
if err == nil {
c.data[key] = entry{
value: val,
expiresAt: time.Now().Add(c.ttl),
staleAt: time.Now().Add(c.ttl + c.staleTTL),
}
}
c.mu.Unlock()
call.wg.Done()
}()
return e.value, nil // отдаём stale значение
}
// Полностью устаревшее — ждём вычисления
return c.computeAndStore(key)
}
Оптимизация: sharding для снижения конкуренции
При 5k rps один мьютекс может стать узким местом. Шардирование распределяет нагрузку:
const shardCount = 32
type ShardedCache struct {
shards [shardCount]*TTLCache
}
func NewShardedCache(ttl time.Duration, compute func(string) (interface{}, error)) *ShardedCache {
sc := &ShardedCache{}
for i := 0; i < shardCount; i++ {
sc.shards[i] = NewTTLCache(ttl, compute)
}
return sc
}
func (sc *ShardedCache) getShard(key string) *TTLCache {
// FNV-1a hash — быстрый и хорошее распределение
h := uint32(2166136261)
for i := 0; i < len(key); i++ {
h ^= uint32(key[i])
h *= 16777619
}
return sc.shards[h%shardCount]
}
func (sc *ShardedCache) Get(key string) (interface{}, error) {
return sc.getShard(key).Get(key)
}
Ключевые выводы для интервью:
RWMutex— правильный выбор для read-heavy нагрузки (5k rps чтения, редкие записи)- Double-check после получения блокировки предотвращает повторное вычисление
- Singleflight pattern защищает от thundering herd при истечении TTL
- Stale-while-revalidate гарантирует нулевую задержку даже при обновлении кэша
- Sharding снижает конкуренцию на мьютексе при большом количестве ключей
- Фоновая очистка предотвращает утечку памяти от устаревших записей
- Для production стоит рассмотреть
golang.org/x/sync/singleflightвместо собственной реализации
