Разбор популярного тестового задания с упором на системный дизайн
- среда, 30 сентября 2026 г. в 00:00:10
Эта статья может быть полезна студентам и начинающим специалистам, занимающимся разработкой веб-сервисов. Примеры кода приводятся на языке Go, некоторые особенности языка программирования разъясняются сразу по месту использования. В статье рассматривается одно из довольно типичных тестовых заданий как на навыки программирования, так и на навыки системного дизайна. В жизни встречаются разные формулировки этого тестового задания, но общий шаблон как задачи, так и решения вы наверняка узнаете, если вам уже доводилось сталкиваться с подобным заданием или если оно попадется вам в будущем.
Есть довольно старый анекдот про конфликты интересов заказчика (менеджера) и разработчика ПО. Вначале напомню анекдот, а потом поясню причем тут системный дизайн.
Беседуют как-то программист и проджект-менеджер за рюмкой чая, обсуждают философские проблемы.
И менеджер задает наболевший вопрос:
- Слушай, вот скажи мне, почему вы, разработчики, никогда не можете адекватно оценить время, необходимое для реализации проекта?
Программист отвечает:
- Сейчас объясню на понятном примере, ты сам все поймешь. Вот тебе задача - тебе нужно разгрузить машину, за сколько ты управишься?
Менеджер:
- Примерно за час.
Программист:
- А если это КАМАЗ?
Менеджер:
- Пусть будет тогда четыре часа. Удвоим для подстраховки - за восемь часов справлюсь.
Программист:
- Разгружать нужно песок и инструментов никаких нет, только руки и ноги.
Менеджер:
- Хорошо, три дня. Если не буду успевать, поработаю сверхурочно.
Программист:
- А если КАМАЗ находится под водой?
Менеджер
- Так нельзя - ты же постоянно новые условия вводишь! Почему ты не можешь сразу сформулировать задачу точно и полностью?
Программист:
- Ну что - теперь понял в чем трудность оценки трудоемкости проектов?
Мораль этой истории такая - чтобы оценить задачу, спроектировать адекватное решение и дать какие-то оценки по трудоемкости, стоит вначале задать уточняющие вопросы и зафиксировать требования.
Сейчас многие интервьюеры любят строить собеседования примерно по такому же плану, как в анекдоте, то есть начинают с расплывчатой формулировки задачи, а затем вводят дополнительные условия по ходу решения. Это не плохо и не хорошо, просто это отражает реальные ситуации в бизнесе. Лет 10-15 назад достаточно было выяснить функциональные требования - то есть уточнить, что программа должна делать. Сейчас для успешного прохождения собеседования от разработчика ожидается также работа с нефункциональными требованиями. Проектирование с учетом нефункциональных требований обычно и является основной целью собеседований с уклоном в системный дизайн. Теперь давайте разберем все это на конкретном примере.
Необходимо спроектировать веб-сервис - рейтинг очков игроков в игре. Функции такие: принимать новые значения очков игрока, показывать место игрока в рейтинге вместе с соседями, показывать топ-10 списка.
На первый взгляд кажется, что все четко - функциональные требования описаны, можно приступать.
Но если подумать немного, то найдется что уточнить. Задачи для собеседований или тестовых заданий почти всегда построены так, что многое нужно уточнить, прежде чем решать задачу. Иногда ваших вопросов ожидают и отвечают конкретно, иногда говорят - решите сами. Важно одно - лучше задать вопрос и уточнить, чем самому что-то додумать без согласования. Это справедливо и для тестовых заданий и для рабочих задач. Кстати, по задаваемым вопросам интервьюер уже может дать предварительную оценку грейда собеседуемого.
Как идентифицируется игрок - символьный никнейм (логин, email, uuid) или уникальный числовой id? Показывая соседей и топ рейтинга как мы должны идентифицировать игроков в ответе?
Типичный ответ на эти вопросы такой: игроки идентифицируются уникальным id (обычно целочисленным). Где-то за пределами нашего сервиса хранится идентификационная информация для игроков, игрокам присваиваются числовые идентификаторы, которыми мы можем оперировать. Соответственно соседей и топ мы выдаем, идентифицируя игроков уникальным целочисленным id. Если клиенту понадобится информация об игроке по его id, клиент обратится к другому сервису сам. Наша задача - обрабатывать запросы как можно быстрее.
Какое количество игроков всего ожидается? Какое количество или процент активных игроков (обновляющих данные каждый день) ожидается? Как часто обновляют данные активные игроки? Ответы на эти вопросы позволяют оценить нагрузку на наш сервер.
В ответ на эти вопросы либо сразу дают данные большой нагрузки, либо вначале сообщают данные средней нагрузки, а потом спрашивают как изменится система, если нагрузка вырастет в 100 или более раз.
Давайте пойдем по пути умеренно высокой нагрузки для начала. Предположим, что ожидается 1 млн игроков, 20% активных игроков, обновляющих данные ежедневно. Активные игроки играют в среднем по 4 часа в день. Каждый игрок может обновлять свой счетчик в среднем каждые 5 минут. Игроки в основном (более 90%) распределены по 6 смежным часовым зонам.
Рассчитаем предполагаемую нагрузку. Активные игроки обновляют счетчик до 12 раз в час в течение 4 часов - 48 обновлений в день. Активных игроков 200 000, значит всего в день ожидается 9,6 млн запросов. Игроки распределены по 6 смежным часовым зонам, т.е. можем предположить, что основная нагрузка будет распределена в течение 10 часов. Рассчитаем среднюю нагрузку при равномерном распределении - 960 000 запросов в час - 267 запросов в секунду. Пиковая нагрузка может быть в 2-3 раза выше, то есть 500-800 запросов в секунду.
Как часто происходят запросы на чтение данных?
Ответ: после каждой игровой сессии игрок может запросить свое место в рейтинге или топ рейтинга. Стоит ожидать, что в 20% случаев игрок захочет увидеть свое место в рейтинге сразу после отправки нового значения счетчика. Это увеличивает рассчитанную выше нагрузку на 20%. Это означает, что наша цель - обеспечить обработку запросов примерно за 1 миллисекунду.
Примеры кода будут на языке программирования Go. Минимальное API будет представлено такими маршрутами.
POST /score/update
GET /score/top10
GET /score/rank/{id}
Информация об игроке может быть представлена такой структурой.
type PlayerAttributes struct { Id uint64 `json:"id"` Score uint64 `json:"score"` // текущий балл игрока Rank uint64 `json:"rank"` // место в рейтинге }
Параметры POST /score/update можно представить структурой.
type UpdatePlayerScoresParams struct { Id uint64 `json:"id" validate:"required,numeric"` Score uint64 `json:"score" validate:"required,numeric"` }
Два других запроса не используют параметры в теле запроса.
Задача примитивная, никакой бизнес-логики на первый взгляд не просматривается, поэтому достаточно будет реализовать приложение из двух слоев - контроллер и репозиторий. Все хитрости хранения данных будут в слое репозитория.
Основные требования к хранилищу такие:
Хранилище должно держать нагрузку на запись до 800 операций в секунду.
Параллельно с записью хранилище должно без проблем выполнять до 200 операций поиска сортированных данных в секунду.
Общий объем данных - примерно 1 млн записей. Учитывая, что каждая запись занимает 32 байта, общий объем данных потребует от 32 мегабайт, что немного, поэтому рабочую БД можно полностью разместить в оперативной памяти, время от времени сбрасывая ее состояние на диск.
Под эти требования отлично подходит Redis. В зависимости от оборудования Redis способен держать нагрузку в десятки и сотни тысяч операций записи в секунду. Стоит попробовать реализовать хранение всех данных для этой задачи в Redis и провести тесты. Если после всех оптимизаций приложение не даст нужной производительности, тогда можно будет подумать над другими вариантами организации хранилища.
Организация хранения данных в Redis для данной задачи
В Redis есть такой тип данных - упорядоченное множество (Sorted Set). Такие множества хранятся в одной записи (с одним ключем). Есть команды добавления данных в такие множества и чтения данных из таких множеств.
Элементы отсортированных множества состоят из score и member. Score - это вес - значение, которое используется для сортировки множества, его тип - число с плавающей точкой. Для нашей задачи - это очки игрока (PlayerAttributes.Score), по которым строится рейтинг игроков. Member - это полезная нагрузка элемента множества, представленная уникальной строкой. Если попытаться записать повторно одно и то же значение с другим score, в множестве перезапишется Score для ранее существовавшего значения. Для нашей задачи в качестве member будет использоваться PlayerAttributes.Id.
Структуру данных об игроке, если потребуется, можно сохранить отдельно как JSON-строку с ключом Id. В простейшем случае структуру, описанную выше, состоящую из Id, Score и Rank вообще не нужно нигде сохранять - Id, Score будем получать из множества, а Rank - позицию в множестве - тоже будем получать при чтении значений из множества.
Так как в нашей задаче не предполагается искать данные по каким-либо другим полям, кроме PlayerAttributes.Score и PlayerAttributes.Id, то описанной модели данных будет вполне достаточно. Для удобства использования изменим тип PlayerAttributes.Id с числа на строку.
Код обработчиков маршрутов API будет типичен и не так интересен, как код репозитория. Давайте напишем код, выполняющий нужные операции, и посмотрим, как его можно улучшить.
UpdatePlayerScore
При обновлении баллов игрока выполняется запись в упорядоченное множество командой ZAdd и затем определяется место игрока в рейтинге операцией ZRevRank, после чего формируется структура ответа PlayerAttributes.
package ScoreRepo import ( "context" "encoding/json" "log" "player_score/internal/score/models" "strconv" "time" "github.com/redis/go-redis/v9" ) type PlayerScoreRepo struct { rdb *redis.Client } func NewPlayerScoreRepo(rdb *redis.Client) *PlayerScoreRepo { return &PlayerScoreRepo{ rdb: rdb, } } func (this *PlayerScoreRepo) UpdatePlayerScore( id string, score uint64, ) ( player *models.PlayerAttributes, err error, ) { ctx := context.Background() _, err = this.rdb.ZAdd(ctx, "leaderboard", redis.Z{ Score: float64(score), Member: id, }).Result() if err != nil { return nil, err } rank, err := this.rdb.ZRevRank(ctx, "leaderboard", id).Result() if err != nil { return nil, err } player = &models.PlayerAttributes{ Id: id, Score: score, Rank: uint64(rank + 1), } return player, err }
ZRevRank - операция поиска места по значению Member, который в нашем случае является идентификатором игрока, ожидает строку в качестве Member. Для формирования ответа нужно последовательно вызвать две команды Redis, распараллелить вызовы нельзя, результат второго вызова зависит от первого вызова, так как при изменении множества может произойти изменение рейтинга игрока.
FindTop10
Для получения лучших игроков (Top-10) воспользуемся командой ZRevRangeWithScores, которая возвращает массив элементов упорядоченного множества от большего значения score к меньшему. Индексация идет с 0 и для Top-10 мы запрашиваем элементы с 0 по 9 - то есть 10 наибольших значений score. Результат возвращается в массиве структур типа:
type Z struct { Score float64 Member interface{} }
Балл Score является числом с плавающей точкой, а Member - интерфейс любого типа, но по факту там ожидается строка. Поэтому для преобразования потребуется “утверждение типа”.
id, ok := z.Member.(string)
Это операция, которая пытается привести значение интерфейса к нужному типу и возвращает вторым результатом успешность приведения к типу.
func (this *PlayerScoreRepo) FindTop10() ( top []models.PlayerAttributes, err error, ) { ctx := context.Background() leaderboard, err := this.rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result() for i, z := range leaderboard { id, ok := z.Member.(string) if ok { if err == nil { player := models.PlayerAttributes{ Id: id, Score: uint64(z.Score), Rank: uint64(i + 1), } top = append(top, player) } } } return top, nil }
Клиент ожидает места с 1 по 10, поэтому в выходной структуре Rank - это место игрока в множестве +1. Метод выполняет одну команду Redis.
FindPlayerWithNeighbors
Чтобы найти игрока применяется уже знакомая нам команда Redis поиска значения ZRevRank. Получив место игрока, можно запросить группу значений. На этот раз потребуется подготовить startRank и endRank для вызова ZRevRangeWithScores. На случай, если наш искомый игрок и так уже имеет rank = 0 и rank-1 для него может стать отрицательным, мы воспользуемся функцией max(rank-1, 0).
func (this *PlayerScoreRepo) FindPlayerWithNeighbors( id string, ) ( players []models.PlayerAttributes, err error, ) { ctx := context.Background() rank, err := this.rdb.ZRevRank(ctx, "leaderboard", id).Result() if err != nil { return players, err } startRank := max(rank-1, 0) members, err := this.rdb.ZRevRangeWithScores( ctx, "leaderboard", max(rank-1, 0), rank + 1, ).Result() for i, z := range members { id, ok := z.Member.(string) if ok { player := models.PlayerAttributes{ Id: id, Score: uint64(z.Score), Rank: uint64(startRank) + uint64(i+1), } players = append(players, player) } } return players, nil }
Репозиторий готов. Логика тут простая - оптимизировать особо нечего. Команды Redis должны вызываться последовательно, и следующая команда использует данные, полученные из предыдущей. Для таких ситуаций возможности оптимизации есть (например, скрипты, которые выполняются на стороне Redis), но это выходит за рамки данной статьи.
Далее можно написать тест и проверить скорость работы операций репозитория. Этот тест не будет учитывать накладные расходы по протоколу HTTP, но даст примерное представление насколько быстро будут работать операции с данными.
package ScoreRepo import ( "context" "fmt" "math/rand/v2" "time" "strconv" "testing" "github.com/redis/go-redis/v9" ) func TestRepo(t *testing.T) { rdb := redis.NewClient(&redis.Options{ Addr: "localhost:6379", Password: "", PoolSize: 1000, DB: 7, // номер базы данных по умолчанию }) ctx := context.Background() pong, err := rdb.Ping(ctx).Result() if err != nil { panic(err) } fmt.Println("Redis ping:", pong) repo := NewPlayerScoreRepo(rdb) repo.Trancate() testInitialMassUpdatePlayerScore := func() { count := 100_000 fmt.Println("\nWriting test records:", count) start := time.Now() for i := range count { repo.UpdatePlayerScore( strconv.Itoa(i+1), uint64(rand.Int64N(int64(count))), ) } duration := time.Since(start) fmt.Println("Test records wrote in:", duration) fmt.Println("Average write record time:", duration/time.Duration(count)) } testUpdateAndFind := func() { count := 1_000 fmt.Println("\nUpdate and FindTop10 for records count:", count) var durationUpdate time.Duration var durationFindTop10 time.Duration var durationFindPlayer time.Duration for i := range count { start1 := time.Now() repo.UpdatePlayerScore( strconv.Itoa(i+1), uint64(count+i), ) durationUpdate += time.Since(start1) start2 := time.Now() repo.FindTop10() durationFindTop10 += time.Since(start2) start3 := time.Now() repo.FindPlayerWithNeighbors(strconv.Itoa(i + 1)) durationFindPlayer += time.Since(start3) } speed1 := durationUpdate / time.Duration(count) speed2 := durationFindTop10 / time.Duration(count) speed3 := durationFindPlayer / time.Duration(count) rps1 := int64(time.Second) / int64(speed1) rps2 := int64(time.Second) / int64(speed2) rps3 := int64(time.Second) / int64(speed3) fmt.Println("\nTest records updated in:", durationUpdate) fmt.Println("Average update record time:", speed1) fmt.Println("Update RPS:", rps1) fmt.Println("\nTest finding top done in:", durationFindTop10) fmt.Println("Average find top time:", speed2) fmt.Println("Find RPS:", rps2) fmt.Println("\nTest finding player done in:", durationFindPlayer) fmt.Println("Average find player time:", speed3) fmt.Println("Find RPS:", rps3) } testInitialMassUpdatePlayerScore() testUpdateAndFind() }
Запустив этот тест на ноутбуке с процессором Intel Core Ultra 225H (с подключенным питанием) я получил такие результаты.
Writing test records: 100000 Test records wrote in: 32.0468527s Average write record time: 320.468us Update and FindTop10 for records count: 1000 Test records updated in: 312.8766ms Average update record time: 312.876us Update RPS: 3196 Test finding top done in: 156.1271ms Average find top time: 156.127us Find RPS: 6405 Test finding player done in: 312.4308ms Average find player time: 312.43us Find RPS: 3200
Метод, состоящий из одной команды Redis отрабатывает примерно за 156 микросекунд, а методы, вызывающие 2 команды Redis отрабатывают в среднем за 312-320 микросекунд. Что обеспечивает производительность более 3000 rps.
На уровне репозитория результаты неплохие, но нет большого задела на рост нагрузки. Поэтому стоит написать тест контроллера, чтобы прикинуть сколько накладных расходов добавляет HTTP-обвязка и какой реальный уровень запросов в секунду сможет вывозить сервис.
package ScoreController import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" ScoreRepo "player_score/internal/score/repo" "testing" "time" "github.com/redis/go-redis/v9" ) func updateRequest( client *http.Client, baseUrl string, payload UpdatePlayerScoresParams, ) (duration time.Duration, statusCode int) { // 1. готовим пейлод jsonData, err := json.Marshal(payload) if err != nil { fmt.Printf("Ошибка сериализации JSON: %v\n", err) return } // 2. Создаем запрос, без api в пути // так как тестируется конкретный котроллер и он монтируется в корень сервера // url := baseUrl + "/score/update" req, err := http.NewRequest( "POST", url, bytes.NewBuffer(jsonData), ) if err != nil { fmt.Printf("Ошибка создания запроса: %v\n", err) return } // 3. Обязательно устанавливаем заголовок Content-Type req.Header.Set("Content-Type", "application/json") start := time.Now() // 4. Запрос resp, err := client.Do(req) duration = time.Since(start) if err != nil { fmt.Printf("Ошибка отправки: %v\n", err) return } defer resp.Body.Close() _, err = io.ReadAll(resp.Body) if err != nil { fmt.Printf("Ошибка чтения: %v", err) } // log.Println(string(body)) return duration, resp.StatusCode } func TestScoreHttpController(t *testing.T) { rdb := redis.NewClient(&redis.Options{ Addr: "localhost:6379", Password: "", PoolSize: 1000, DB: 7, // номер базы данных по умолчанию }) ctx := context.Background() pong, err := rdb.Ping(ctx).Result() if err != nil { panic(err) } fmt.Println("Redis ping:", pong) scoreController := NewScoreHttpController( ScoreRepo.NewPlayerScoreRepo(rdb), ) testServer := httptest.NewServer(scoreController.Handler) defer testServer.Close() client := testServer.Client() count := 10_000 var durationUpdate time.Duration for i := range count { duration, _ := updateRequest( client, testServer.URL, UpdatePlayerScoresParams{ Id: uint64(i), Score: uint64(i), }, ) durationUpdate += duration } speed1 := durationUpdate / time.Duration(count) rps1 := int64(time.Second) / int64(speed1) fmt.Println("\nRecords to update count:", count) fmt.Println("Test records updated in:", durationUpdate) fmt.Println("Average update record time:", speed1) fmt.Println("Update RPS:", rps1) }
В этот тесте запускается сервер и запросы проходят через сетевой стек операционной системы. То есть это полноценная имитация работы реального HTTP-сервера под нагрузкой. Запуск этого теста дал такие результаты.
Records to update count: 10000 Test records updated in: 4.6652952s Average update record time: 466.529us Update RPS: 2143
Более 2000 rps вывозит текущая реализация на бытовом ноутбучном процессоре. Условия задачи выполнены. Текущая реализация сервиса скорее всего без проблем выдержит требуемую нагрузку на современном серверном оборудовании, даже с учетом виртуализации и контейнеризации. На этом можно остановиться.
Однако, существенного запаса производительности нет. При росте нагрузки в 5-10 раз придется задуматься о возможных решениях по масштабированию.
Масштабирование информационных систем бывает вертикальное и горизонтальное.
Вертикальное масштабирование - это увеличение мощности серверов за счет выделения дополнительных ресурсов - оперативной памяти, процессорных ядер, использования более быстрых сетевых интерфейсов. При этом есть реальный физический предел роста.
Горизонтальное масштабирование - это изменение архитектуры решения для распределения нагрузки на несколько серверов. В этом случае пределы роста более обширные. Но этот путь заметно повышает сложность решений.
В нашем случае вертикальное масштабирование за счет наращивания памяти или процессорных ядер не даст существенного прироста. Памяти мы расходуем немного - менее 1 ГБ. Структура расхода времени на обслуживание запроса по результатам тестирования примерно такая - 2/3 времени занимает обработка на уровне репозитория и 1/3 времени - обработка HTTP. Особенности архитектуры Redis заключаются в том, что непосредственно команды выполняются в одном потоке, увеличение числа процессорных ядер может ускорить лишь сетевое взаимодействие. Поэтому на уровне Redis и нашего репозитория удвоение числа процессорных ядер, например с 4 до 8 не дает пропорционального увеличения производительности в 2 раза. В самом лучшем случае можно прогнозировать рост производительности на 20-30%.
Заметное вертикальное масштабирование можно получить в случае повышения производительности процессора и пропускной способности памяти. Например, я запускал эти тесты на Macbook Air M5 и время обработки запросов было в разы лучше. Например, обновление данных на уровне репозитория выдавало почти 24 000 rps, вместо 3196 rps на процессоре Intel (почти в 8 раз больше). На уровне контроллера результат был почти 11 000 rps, вместо 2143 rps на процессоре Intel (в 5 раз больше).
Очень часто нет возможности наращивать производительность одного сервера, зато можно добавить еще несколько серверов. И горизонтальное масштабирование - это основной способ наращивания производительности в наше время.
Каким образом можно реализовать горизонтальное масштабирование для этой задачи? Классический подход - делить запросы и сервера данных. В нашем случае это означает - менять концепцию и отказываться от первоначальной идеи единого рейтинга на миллионы пользователей с мгновенным обновлением. То есть чтобы повысить производительность придется менять функциональные требования и проектные решения.
Например, рейтинг будет обновляться не мгновенно. Команды на апдейт данных будут выстраиваться в очередь. Клиентский сервис будет просто класть запросы в очередь. А отдельный сервис будет потреблять эту очередь и обновлять данные в Redis. При этом клиент будет быстро получать ответ что данные сохранены (в случае протокола HTTP - примерно в 3 раза быстрее), но не будет получать текущее значение своего рейтинга. Для получения рейтинга ему нужно будет сделать отдельный запрос через какой-то промежуток времени (несколько секунд). Это даст задержку в течение которой очки игрока будут записаны в БД и можно будет узнать его рейтинг с учетом последних изменений. Это позволит увеличить число игроков на одном сервере рейтинга в разы. Потенциал этого подхода вместе в вертикальным масштабированием - рост числа клиентов на порядок, не более.
Другой вариант - делить игроков и их рейтинги по регионам, лигам, уровням привилегий или еще как-то. Основная задача - уменьшить количество запросов к одному серверу Redis и дать возможность задействовать несколько серверов Redis. У этого подхода потенциал более высокий. Поэтому в популярных играх и применяется деление на регионы. К сожалению, по-другому никак на получится справится с ростом числа игроков в десятки раз.
В этой статье я рассмотрел общий алгоритм работы над тестовым заданием, а именно - вначале уточняем требования, задаем все возникающие вопросы, а только потом приступаем к проектированию с учетом полученной информации. Во время собеседования не обязательно показывать реализацию задачи полностью. Достаточно выделить главные проблемы и показать их решения. Типичные шаблонные действия, вроде реализации контроллеров HTTP-серверов, сейчас уже мало кого интересуют.
В статье я показал основные фрагменты реализацию задачи с помощью Redis и Golang. Главным образом для того, чтобы провести тесты и получить реальные данные о производительности решения. Затем я показал возможные пути масштабирования производительности для этой задачи. Вероятно, существуют и другие варианты. Буду рад обсудить их в комментариях.