Реальный собес Avito: живые задачи и разбор тестового
Это целиком чужой опыт, не мой. По рассказу знакомого (назовём его Аскар) — он проходил собеседование на Go-бэкенд в Avito весной 2026, структура и все три живые задачи ниже переданы дословно с его слов. Формат его собеса: self-intro → долгий разбор его тестового задания → три задачи на живое кодирование → блок теории.
Ниже на этой странице — только то, что случилось с Аскаром: как устроен разбор тестового на таком собесе в принципе, и три конкретные задачи, которые давали именно ему. Как только материал переходит к разбору МОЕГО СОБСТВЕННОГО тестового задания (реальный код, который писал я) — это уже другая, отдельная папка «Тестовое задание» в дереве слева, начинается со статьи «Защита тестового Avito на реальном проекте: Avito.Кухня». Граница ровно здесь: всё в этой папке «Аскар — реальный собес» — про него, всё в папке «Тестовое задание» — про мой код.
Как защищают своё тестовое
После рассказа о себе большая часть времени уходит не на новые задачи, а на разбор уже сданного тестового:
- "почему выбрал такой подход, чем он лучше альтернативы";
- "что вернётся, если тут будет ошибка, можно ли обработать иначе";
- "зачем тебе тут интерфейсы, а не конкретный тип";
- "выдержит ли это 100 RPS";
- "в тестах у тебя in-memory хранилище вместо Postgres — какие у этого недостатки".
Ни один из этих вопросов не проверяет знание синтаксиса — все проверяют, можешь ли ты вслух аргументировать решение, которое сам же принял неделю назад. Ниже в разделе про кэширование — конкретный пример, почему "выдержит ли 100 RPS" не риторический вопрос.
Задача 1: гонка на горутинах без синхронизации
Дают код, спрашивают: что выведет, что не так, как исправить.
Перед прочтением ответа — не гадай, а прогони сам с go run -race. Здесь два независимых бага, и второй прячется за первым.
Первый: main не ждёт горутины. Ни sync.WaitGroup, ни канала — main доходит до Printf сразу после запуска цикла, не зная, успела ли отработать хоть одна горутина. На этой машине (22 логических ядра) реальный вывод — стабильно Max: 1000 при пяти запусках подряд: планировщик успевает выполнить первую (и самую большую чётную) горутину до Printf, а раз i дальше только убывает, никто её не перебьёт.
Но с GOMAXPROCS=1 тот же бинарник пять раз подряд печатает Max: 0 — горутины на одном ядре просто не получают шанса выполниться до того, как main дойдёт до Printf, потому что в цикле нет ни одной точки, где рантайм обязан переключить горутину. Программа с неопределённым результатом, который зависит от числа ядер — это баг, даже если на конкретном железе он выглядит "стабильным".
Второй, независимо от первого: гонка на max. Даже если бы main дождался всех горутин, чтение i > max и запись max = i в одной горутине не синхронизированы с тем же чтением/записью в другой — go run -race находит её мгновенно:
WARNING: DATA RACE
Read at 0x00c000018168 by goroutine 10
Previous write at 0x00c000018168 by goroutine 8
Правильная версия ждёт все горутины и защищает max мьютексом:
package main
import (
"fmt"
"sync"
)
func main() {
var max int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 1000; i > 0; i-- {
wg.Add(1)
go func() {
defer wg.Done()
if i%2 == 0 {
mu.Lock()
if i > max {
max = i
}
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Printf("Max: %d\n", max)
}go run -race на этой версии чист, и результат Max: 1000 больше не зависит от количества ядер. Захват i в замыкании отдельной проблемой уже не является: начиная с Go 1.22 у каждой итерации for — своя копия переменной цикла, а не общая на весь цикл (до 1.22 без явного i := i внутри тела все горутины видели бы одну и ту же переменную).
Задача 2: палиндром с ловушкой на юникоде
Первая часть — написать проверку строки на палиндром, вторая — "а что если строка на китайском, что сломается".
func isPalindrome(s string) bool {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
if s[i] != s[j] {
return false
}
}
return true
}Индексация s[i] в Go — это байт строки, не символ. Для ASCII разницы нет, но UTF-8 кодирует не-латинские символы несколькими байтами:
fmt.Printf("%q: len=%d rune-len=%d\n", "上海海上", len("上海海上"), len([]rune("上海海上")))
// "上海海上": len=12 rune-len=4上海海上 — настоящий палиндром из 4 иероглифов, но каждый занимает 3 байта, так что побайтовое сравнение зеркалит не те позиции и возвращает false там, где реальный ответ — true. Проверено на трёх строках: "level" (bytes=true, runes=true — совпадают на ASCII), "上海海上" (bytes=false, runes=true — расхождение), "日本本日語" (не палиндром вообще, bytes=false, runes=false — совпадают, потому что оба честно говорят "нет"). Расхождение проявляется именно на настоящих не-ASCII палиндромах.
Починка — работать с []rune, а не с байтами строки:
func isPalindrome(s string) bool { for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { if s[i] != s[j] { return false } } return true}func isPalindrome(s string) bool { r := []rune(s) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { if r[i] != r[j] { return false } } return true}[]rune(s) декодирует UTF-8 в срез code point'ов — на строке из чистого ASCII это просто более дорогая копия (лишняя аллокация под срез), но именно она делает индексацию корректной вне ASCII. Для очень горячего пути, где вход заведомо ASCII, это компромисс между корректностью и одной лишней аллокацией — на собесе стоит проговорить это явно, а не молчать про цену исправления.
Задача 3: улучшить существующий метод — кандидат предложил кэширование
В оригинале — ручка, отдающая погоду. Кандидату показывают его код и просят проанализировать и улучшить; он предлагает кэш и его же просят сразу реализовать. Здесь и всплывает вопрос из разбора тестового про 100 RPS — потому что "добавить кэш" и "добавить кэш, который переживёт 100 RPS" не одно и то же.
Наивная версия — TTL-кэш под мьютексом, без учёта параллельных промахов:
type NaiveCache struct {
mu sync.Mutex
value string
expires time.Time
}
func (c *NaiveCache) Get(city string) string {
c.mu.Lock()
if time.Now().Before(c.expires) {
v := c.value
c.mu.Unlock()
return v
}
c.mu.Unlock()
v := fetchUpstream(city) // медленный внешний вызов
c.mu.Lock()
c.value = v
c.expires = time.Now().Add(time.Second)
c.mu.Unlock()
return v
}type WeatherCache struct {
mu sync.Mutex
value string
expires time.Time
inFlight chan struct{}
}
func (c *WeatherCache) Get(city string) string {
c.mu.Lock()
if time.Now().Before(c.expires) {
v := c.value
c.mu.Unlock()
return v
}
if c.inFlight != nil {
ch := c.inFlight
c.mu.Unlock()
<-ch // кто-то уже пошёл в апстрим — просто ждём результат
c.mu.Lock()
v := c.value
c.mu.Unlock()
return v
}
ch := make(chan struct{})
c.inFlight = ch
c.mu.Unlock()
v := fetchUpstream(city)
c.mu.Lock()
c.value, c.expires, c.inFlight = v, time.Now().Add(time.Second), nil
c.mu.Unlock()
close(ch)
return v
}Разница видна не в чтении кода, а в нагрузочном тесте: 50 горутин одновременно бьют в пустой (только что протухший) кэш. Наивная версия честно ходит в апстрим 50 раз — каждая горутина успевает проверить expires, не увидеть свежего значения и запустить свой собственный fetchUpstream, пока кэш ещё не обновился. Версия с inFlight-каналом ходит в апстрим ровно один раз: все остальные 49 горутин встают в очередь на канал первой и получают её результат.
upstream calls for 50 concurrent requests (naive): 50
upstream calls for 50 concurrent requests: 1
Это и есть содержательный ответ на "а что будет при 100 RPS": наивный кэш на всплеске одновременных промахов (например, сразу после протухания TTL) устраивает upstream-сервису "стадный набег" (thundering herd) — по факту кэш почти бесполезен именно в момент пиковой нагрузки, когда он нужнее всего. go run -race на обеих версиях чист — сама по себе гонка здесь не в этом; проблема в отсутствии координации между параллельными промахами, а не в неопределённом поведении памяти.
Быстрый рефреш теории: слайсы и интерфейсы
Горутины, каналы и разницу с ОС-потоками смотри в «Горутины и каналы» — здесь только то, что не пересекается: срезы и интерфейсы.
len/cap и когда append делит backing array. append возвращает новый срез поверх того же массива, пока хватает cap; как только не хватает — выделяется новый массив, и старый срез (с прежним cap) продолжает смотреть на старую память:
c := make([]int, 2, 4)
d := c
c = append(c, 3) // cap=4, место есть — d и c делят один массив
c[0] = 111
fmt.Println(d[0]) // 111 — d видит чужую запись
a := make([]int, 0, 2)
a = append(a, 1, 2) // len=2, cap=2 — забито под завязку
b := a
a = append(a, 3) // cap не хватило — новый массив, b и a больше не связаны
a[0] = 999
fmt.Println(b[0]) // 1 — b не пострадалInterface, хранящий typed nil, сам не равен nil. Классическая ловушка, когда функция объявлена как func() error, но внутри возвращает переменную конкретного указательного типа:
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func mayFail(fail bool) error {
var err *MyError // nil-указатель, но с известным типом
if fail {
err = &MyError{msg: "boom"}
}
return err // всегда упаковывается в error как (*MyError, nil-значение)
}err := mayFail(false)
fmt.Println(err == nil) // falseИнтерфейс в Go — это пара (тип, значение). error, вернувший (*MyError)(nil), хранит непустой тип и nil-значение — как пара это не равно "полностью пустому" интерфейсу nil. Отсюда практическое правило: если функция может не найти ошибку, она должна явно return nil, а не возвращать типизированную nil-переменную через интерфейсный тип.
Почему кандидат не прошёл
Отбор этой весной не прошёл, по собственным словам, из двух связанных причин: не хватило уверенности защитить своё же тестовое на созвоне, и реализация одной из ручек теряла бы часть пользователей на непокрытом edge case — по оценке интервьюера, около 10%. Дело не в незнании синтаксиса — оба навыка (устойчиво объяснить решение под вопросами и заранее найти собственный непокрытый кейс) это ровно то, что тренируют разборы чужого кода, а не написание нового с нуля.