Задача из собеседования (VK, intern-аналитик): медиана зарплат по отделам без MEDIAN 📊
Любимый вопрос на собесах: проверяет понимание оконных функций и аккуратность с чётным/нечётным числом строк.
📋 Схема БД
🔹 employees (id, name, department, salary)
🎯 Формулировка
Посчитать медианную зарплату для каждого отдела. Использовать MEDIAN / PERCENTILE_CONT нельзя — нужно реализовать руками.
📊 Пример данных
Ожидаемый результат:
Разбор
🧠 Что такое медиана на пальцах
🔹 Сортируем зарплаты по возрастанию
🔹 Если строк нечётно — берём центральную
🔹 Если чётно — среднее двух центральных
Для отдела с зарплатами [40, 50, 70, 100] медиана = (50 + 70) / 2 = 60.
1️⃣ Что нам нужно знать про каждую строку
Чтобы найти «центральную» строку в отделе, нужны две вещи:
🔹 её позиция в отсортированном списке внутри отдела
🔹 общее число строк в отделе
Обе задачи решают оконные функции — ROW_NUMBER() и COUNT(*) с PARTITION BY department.
2️⃣ Как найти центральные позиции
Формула для центра:
🔹 FLOOR((cnt + 1) / 2) — нижняя центральная
🔹 CEIL((cnt + 1) / 2) — верхняя центральная
Магия в том, что для нечётного cnt обе формулы дают одну и ту же строку — это и есть единственная центральная. Для чётного — две соседние, которые мы потом усредним.
Проверим на наших данных:
🔹 IT (cnt=3): FLOOR(4/2)=2, CEIL(4/2)=2 → берём строку №2 → зарплата 120 ✅
🔹 HR (cnt=2): FLOOR(3/2)=1, CEIL(3/2)=2 → строки №1 и №2 → (60+90)/2 = 75 ✅
3️⃣ Решение через оконные функции
CTE ranked внутри каждого отдела нумерует сотрудников от самой низкой зарплаты к самой высокой и параллельно считает общее число сотрудников. Внешний запрос оставляет только «центральные» строки и усредняет их.
На что обратить внимание❗️
🔹 Деление на 2.0, а не на 2 — иначе в некоторых БД получим целочисленное деление и логика для чётных отделов поедет
🔹 AVG(salary * 1.0) — та же история, если зарплаты хранятся в INT, без умножения на 1.0 результат округлится до целого
🔹 COUNT(*) посчитает и NULL-зарплаты как строки. Если такое возможно — используйте COUNT(salary) и заранее отфильтруйте WHERE salary IS NOT NULL, иначе ROW_NUMBER поставит NULL в начало и сломает порядок
🎁 Бонус: если PERCENTILE_CONT всё-таки доступен
В PostgreSQL, Oracle, SQL Server задача решается в одну строку:
Но на собесе обычно просят именно «руками» — чтобы проверить, понимаете ли вы, что происходит под капотом 🔍
🎯 Что запомнить
🔹 Медиана руками = ROW_NUMBER + COUNT в одном CTE
🔹 Центральные позиции — FLOOR и CEIL от (cnt+1)/2
🔹 Делим на 2.0, а не на 2 — иначе целочисленное деление всё сломает
🔹 Если в БД есть PERCENTILE_CONT — используйте его; ручная реализация нужна только для собесов и старых MySQL (до 8.0 там вообще нет оконных функций — придётся через переменные)
Любимый вопрос на собесах: проверяет понимание оконных функций и аккуратность с чётным/нечётным числом строк.
📋 Схема БД
🔹 employees (id, name, department, salary)
🎯 Формулировка
Посчитать медианную зарплату для каждого отдела. Использовать MEDIAN / PERCENTILE_CONT нельзя — нужно реализовать руками.
📊 Пример данных
id name dept salary
1 Анна IT 80
2 Борис IT 120
3 Вера IT 150
4 Глеб HR 60
5 Дина HR 90
Ожидаемый результат:
dept median
IT 120 ← центр из 3
HR 75 ← (60+90)/2
Разбор
🧠 Что такое медиана на пальцах
🔹 Сортируем зарплаты по возрастанию
🔹 Если строк нечётно — берём центральную
🔹 Если чётно — среднее двух центральных
Для отдела с зарплатами [40, 50, 70, 100] медиана = (50 + 70) / 2 = 60.
1️⃣ Что нам нужно знать про каждую строку
Чтобы найти «центральную» строку в отделе, нужны две вещи:
🔹 её позиция в отсортированном списке внутри отдела
🔹 общее число строк в отделе
Обе задачи решают оконные функции — ROW_NUMBER() и COUNT(*) с PARTITION BY department.
2️⃣ Как найти центральные позиции
Формула для центра:
🔹 FLOOR((cnt + 1) / 2) — нижняя центральная
🔹 CEIL((cnt + 1) / 2) — верхняя центральная
Магия в том, что для нечётного cnt обе формулы дают одну и ту же строку — это и есть единственная центральная. Для чётного — две соседние, которые мы потом усредним.
Проверим на наших данных:
🔹 IT (cnt=3): FLOOR(4/2)=2, CEIL(4/2)=2 → берём строку №2 → зарплата 120 ✅
🔹 HR (cnt=2): FLOOR(3/2)=1, CEIL(3/2)=2 → строки №1 и №2 → (60+90)/2 = 75 ✅
3️⃣ Решение через оконные функции
WITH ranked AS (
SELECT
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary
) AS rn,
COUNT(*) OVER (PARTITION BY department) AS cnt
FROM employees
)
SELECT
department,
AVG(salary * 1.0) AS median_salary
FROM ranked
WHERE rn IN (FLOOR((cnt + 1) / 2.0), CEIL((cnt + 1) / 2.0))
GROUP BY department;
CTE ranked внутри каждого отдела нумерует сотрудников от самой низкой зарплаты к самой высокой и параллельно считает общее число сотрудников. Внешний запрос оставляет только «центральные» строки и усредняет их.
На что обратить внимание
🔹 Деление на 2.0, а не на 2 — иначе в некоторых БД получим целочисленное деление и логика для чётных отделов поедет
🔹 AVG(salary * 1.0) — та же история, если зарплаты хранятся в INT, без умножения на 1.0 результат округлится до целого
🔹 COUNT(*) посчитает и NULL-зарплаты как строки. Если такое возможно — используйте COUNT(salary) и заранее отфильтруйте WHERE salary IS NOT NULL, иначе ROW_NUMBER поставит NULL в начало и сломает порядок
🎁 Бонус: если PERCENTILE_CONT всё-таки доступен
В PostgreSQL, Oracle, SQL Server задача решается в одну строку:
SELECT
department,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median
FROM employees
GROUP BY department;
Но на собесе обычно просят именно «руками» — чтобы проверить, понимаете ли вы, что происходит под капотом 🔍
🎯 Что запомнить
🔹 Медиана руками = ROW_NUMBER + COUNT в одном CTE
🔹 Центральные позиции — FLOOR и CEIL от (cnt+1)/2
🔹 Делим на 2.0, а не на 2 — иначе целочисленное деление всё сломает
🔹 Если в БД есть PERCENTILE_CONT — используйте его; ручная реализация нужна только для собесов и старых MySQL (до 8.0 там вообще нет оконных функций — придётся через переменные)
Please open Telegram to view this post
VIEW IN TELEGRAM
1🔥19👍11❤6🤯6
Больше об CTE по ссылке
https://sql-academy.org/ru/guide/operator-with
https://sql-academy.org/ru/guide/operator-with
🔥17👍8❤6👎1
CHECK-ограничения в MySQL: валидация прямо в таблице ✅
Когда вы хотите убедиться, что в таблицу попадают только корректные данные — не всегда нужно писать сложную логику в приложении. Иногда достаточно встроенного механизма MySQL: ограничения CHECK.
🔹 Что такое CHECK?
Это правило, которое автоматически проверяет значение в колонке при вставке (INSERT) или обновлении (UPDATE). Если правило нарушено — операция отменяется с ошибкой.
Пример:
Теперь нельзя вставить пользователя с возрастом -5 или 999 — MySQL просто не даст это сделать.
Синтаксис CHECK
🔸 Можно добавлять при создании таблицы (CREATE TABLE) или позже (ALTER TABLE).
🔸 Можно использовать AND, OR, арифметику, сравнения, функции (с ограничениями).
🔸 Поддерживается в MySQL 8.0+. В старых версиях CHECK игнорировался (!).
Пример с условием на строку:
Здесь мы запрещаем отрицательные цены.
Добавление ограничения в существующую таблицу:
Что произойдёт при нарушении?
⚠️ Важно помнить:
🔹CHECK срабатывает только для новых данных — старые строки не проверяются.
🔹Если значение NULL, правило обычно не нарушается (NULL считается «неизвестным» и не сравнивается напрямую).
🔹Ошибку можно перехватывать в приложении, чтобы сообщить пользователю.
✅ Когда стоит использовать CHECK:
🔹 Для ограничений: возраст, положительная цена, длина строки, формат (через LIKE или REGEXP).
🔹Когда хотите, чтобы в таблице «по умолчанию» всегда были только корректные данные.
🔹Чтобы упростить валидацию и сделать БД самодостаточной (например, при работе с внешними источниками данных).
Когда вы хотите убедиться, что в таблицу попадают только корректные данные — не всегда нужно писать сложную логику в приложении. Иногда достаточно встроенного механизма MySQL: ограничения CHECK.
🔹 Что такое CHECK?
Это правило, которое автоматически проверяет значение в колонке при вставке (INSERT) или обновлении (UPDATE). Если правило нарушено — операция отменяется с ошибкой.
Пример:
CREATE TABLE users (
id INT PRIMARY KEY,
age INT,
CHECK (age >= 0 AND age <= 120)
);
Теперь нельзя вставить пользователя с возрастом -5 или 999 — MySQL просто не даст это сделать.
Синтаксис CHECK
🔸 Можно добавлять при создании таблицы (CREATE TABLE) или позже (ALTER TABLE).
🔸 Можно использовать AND, OR, арифметику, сравнения, функции (с ограничениями).
🔸 Поддерживается в MySQL 8.0+. В старых версиях CHECK игнорировался (!).
Пример с условием на строку:
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100),
category ENUM('book', 'game', 'toy'),
price DECIMAL(10,2),
CHECK (price >= 0)
);
Здесь мы запрещаем отрицательные цены.
Добавление ограничения в существующую таблицу:
ALTER TABLE users
ADD CONSTRAINT chk_age CHECK (age >= 0 AND age <= 120);
Что произойдёт при нарушении?
INSERT INTO users (id, age) VALUES (1, -10);
-- ERROR 3819 (HY000): Check constraint 'chk_age' is violated.
⚠️ Важно помнить:
🔹CHECK срабатывает только для новых данных — старые строки не проверяются.
🔹Если значение NULL, правило обычно не нарушается (NULL считается «неизвестным» и не сравнивается напрямую).
🔹Ошибку можно перехватывать в приложении, чтобы сообщить пользователю.
✅ Когда стоит использовать CHECK:
🔹 Для ограничений: возраст, положительная цена, длина строки, формат (через LIKE или REGEXP).
🔹Когда хотите, чтобы в таблице «по умолчанию» всегда были только корректные данные.
🔹Чтобы упростить валидацию и сделать БД самодостаточной (например, при работе с внешними источниками данных).
❤16🔥12👍5
🔑 UUIDv4 тихо убивает твою базу
UUID удобен: генерируешь на бэке, ключи уникальны, и никто не подсмотрит число заказов, поменяв цифру в URL. Кажется, идеальный Primary Key.
Но обычный UUIDv4 — полностью случайный. А базе это очень не нравится.
⚙️ Почему так
Индексы живут в B-Tree, а дереву нужен порядок.
🔹 BigInt с автоинкрементом → новая запись дописывается в конец. Мгновенно.
🔹UUIDv4 → случайный ID лезет в середину дерева. База рвёт заполненную страницу пополам и перетасовывает данные. Это и есть Page Split.
📉 Что происходит в продакшене
Пока строк пара сотен тысяч — тишина.
На миллионах начинается боль:
🔹 тормозят INSERT — CPU занят не записью, а перебалансировкой
🔹 фрагментация — страницы полупустые, индекс пухнет в разы
🔹 вымывание кэша — раздутый индекс не лезет в RAM, и база уходит на диск
🚀 Решение — UUIDv7
Отказываться от UUID не нужно. Просто бери седьмую версию.
В начале строки — timestamp (время до миллисекунды), и только потом случайные биты.
🔹 Ключи всегда растут
🔹 Для базы это почти автоинкремент
🔹Записи ложатся в конец, фрагментация исчезает
Скорость вставок остаётся ровной даже на таблицах в десятки гигабайт.
💡 Стартуешь проект? Закладывай UUIDv7 сразу.
UUID удобен: генерируешь на бэке, ключи уникальны, и никто не подсмотрит число заказов, поменяв цифру в URL. Кажется, идеальный Primary Key.
Но обычный UUIDv4 — полностью случайный. А базе это очень не нравится.
⚙️ Почему так
Индексы живут в B-Tree, а дереву нужен порядок.
🔹 BigInt с автоинкрементом → новая запись дописывается в конец. Мгновенно.
🔹UUIDv4 → случайный ID лезет в середину дерева. База рвёт заполненную страницу пополам и перетасовывает данные. Это и есть Page Split.
📉 Что происходит в продакшене
Пока строк пара сотен тысяч — тишина.
На миллионах начинается боль:
🔹 тормозят INSERT — CPU занят не записью, а перебалансировкой
🔹 фрагментация — страницы полупустые, индекс пухнет в разы
🔹 вымывание кэша — раздутый индекс не лезет в RAM, и база уходит на диск
🚀 Решение — UUIDv7
Отказываться от UUID не нужно. Просто бери седьмую версию.
В начале строки — timestamp (время до миллисекунды), и только потом случайные биты.
🔹 Ключи всегда растут
🔹 Для базы это почти автоинкремент
🔹Записи ложатся в конец, фрагментация исчезает
Скорость вставок остаётся ровной даже на таблицах в десятки гигабайт.
💡 Стартуешь проект? Закладывай UUIDv7 сразу.
👍24🔥10👌3❤2
🚀 Поздний JOIN: как ускорить OFFSET на больших данных в 10 раз
Классическая пагинация через
📉 В чём проблема
🔹 Представь поиск 100-й страницы в толстой энциклопедии. Вместо оглавления ты читаешь весь текст с первой страницы, пока не дойдёшь до сотой.
🔹 Так же делает база при
🚀 Решение — Поздний JOIN (Deferred Join)
Сначала мы быстро находим нужные идентификаторы, а уже к ним присоединяем полные данные:
⚙️ Как это работает
🔸 Внутренний запрос работает как оглавление. Он бежит только по легкому индексу (цене и
🔸 Внешний
💡 Этот контринтуитивный трюк спасет твою пагинацию, когда таблица огромная, а отказаться от навигации по номерам страниц нельзя.
Классическая пагинация через
OFFSET сильно замедляет базу. Она читает тысячи тяжелых строк целиком только для того, чтобы их отбросить.📉 В чём проблема
🔹 Представь поиск 100-й страницы в толстой энциклопедии. Вместо оглавления ты читаешь весь текст с первой страницы, пока не дойдёшь до сотой.
🔹 Так же делает база при
OFFSET 100000: она собирает с диска все данные (длинные тексты, даты), отсчитывает ненужные сто тысяч строк и просто выбрасывает их. Это огромная трата ресурсов.🚀 Решение — Поздний JOIN (Deferred Join)
Сначала мы быстро находим нужные идентификаторы, а уже к ним присоединяем полные данные:
SELECT p.*
FROM (
SELECT id FROM products
ORDER BY price
LIMIT 50 OFFSET 100000
) AS sub
JOIN products p ON p.id = sub.id;
⚙️ Как это работает
🔸 Внутренний запрос работает как оглавление. Он бежит только по легкому индексу (цене и
id), мгновенно пропуская 100 тысяч записей, и выдает 50 нужных id.🔸 Внешний
JOIN обращается к самой таблице и загружает тяжелые данные только для этих 50 финальных строк.💡 Этот контринтуитивный трюк спасет твою пагинацию, когда таблица огромная, а отказаться от навигации по номерам страниц нельзя.
🔥28👍9❤7
🥷 Подстава от LIKE: почему 'user_1' находит чужие данные
Все знают, что знак процента (
⚙️ В чём ловушка
В SQL символ
Если ты попытаешься найти конкретного пользователя:
База радостно вернёт не только user_1, но и user-1, userA1 или user91. Подчёркивание сработает как джокер в колоде карт.
🚀 Как починить
Чтобы база искала именно сам символ подчёркивания, его нужно экранировать с помощью оператора
🔹 Мы сами выбираем символ для экранирования (здесь это
🔹 Теперь комбинация
💡 Всегда экранируй спецсимволы в LIKE, чтобы точный поиск не превращался в непредсказуемую лотерею.
Все знают, что знак процента (
%) в поиске заменяет любой кусок текста. Но многие забывают про скрытую угрозу — нижнее подчёркивание.⚙️ В чём ловушка
В SQL символ
_ (underscore) — это тоже спецсимвол. Он означает «ровно один любой знак». Если ты попытаешься найти конкретного пользователя:
SELECT * FROM users WHERE login LIKE 'user_1';
База радостно вернёт не только user_1, но и user-1, userA1 или user91. Подчёркивание сработает как джокер в колоде карт.
🚀 Как починить
Чтобы база искала именно сам символ подчёркивания, его нужно экранировать с помощью оператора
ESCAPE.SELECT * FROM users
WHERE login LIKE 'user!_1' ESCAPE '!';
🔹 Мы сами выбираем символ для экранирования (здесь это
!).🔹 Теперь комбинация
!_ воспринимается базой как обычный текст, а не спецсимвол поиска.💡 Всегда экранируй спецсимволы в LIKE, чтобы точный поиск не превращался в непредсказуемую лотерею.
👍42❤10🔥9🤯3
🧟♂️ Подстава Soft Delete: почему удалённый юзер ломает регистрацию
Ты внедрил мягкое удаление (
⚙️ В чём проблема
🔹 Обычно на колонке
🔹 Но для базы «удалённый» юзер всё ещё существует. Строка физически никуда не делась, поэтому уникальный индекс блокирует новую регистрацию с этим же адресом.
🚀 Идеальное решение
Можно усложнять код бэкенда, но лучше поручить эту задачу самой базе с помощью частичного индекса (Partial Index). Он будет следить за уникальностью только среди активных пользователей.
📈 Почему это круто
🔸 Вся логика защиты от дублей остаётся на уровне БД — никаких костылей в коде.
🔸 Индекс работает быстрее и занимает меньше места, так как просто игнорирует удалённые записи.
💡 Частичный индекс — самое изящное решение для Soft Delete, которое бережёт нервы и дисковое пространство.
Ты внедрил мягкое удаление (
is_deleted = true). Пользователь удаляет аккаунт, а через месяц решает вернуться с тем же email. И тут регистрация падает с ошибкой!⚙️ В чём проблема
🔹 Обычно на колонке
email висит уникальный индекс, чтобы в базе не было клонов.🔹 Но для базы «удалённый» юзер всё ещё существует. Строка физически никуда не делась, поэтому уникальный индекс блокирует новую регистрацию с этим же адресом.
🚀 Идеальное решение
Можно усложнять код бэкенда, но лучше поручить эту задачу самой базе с помощью частичного индекса (Partial Index). Он будет следить за уникальностью только среди активных пользователей.
CREATE UNIQUE INDEX active_users_email_idx
ON users (email)
WHERE is_deleted = false;
📈 Почему это круто
🔸 Вся логика защиты от дублей остаётся на уровне БД — никаких костылей в коде.
🔸 Индекс работает быстрее и занимает меньше места, так как просто игнорирует удалённые записи.
💡 Частичный индекс — самое изящное решение для Soft Delete, которое бережёт нервы и дисковое пространство.
🔥17👍8❤6