/
Sevryukov
/
sql-optimization-test
Обзор
Документация
Войти
/
Sevryukov
/
sql-optimization-test
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
index.html
1 816 строк
106 KB
Sevryukov
docs: add comprehensive README with course overview and usage instructions
25 май 2026, 16:05
25 май 2026, 16:05
3f53e6e
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Оптимизация Баз Данных | Интерактивный Тренажер</title> <style> :root { --bg-primary: #0f172a; --bg-secondary: #1e293b; --bg-tertiary: #334155; --text-primary: #f8fafc; --text-secondary: #94a3b8; --accent-blue: #38bdf8; --accent-green: #10b981; --accent-red: #ef4444; --accent-amber: #f59e0b; --border-color: #475569; --radius-sm: 6px; --radius-md: 12px; --radius-lg: 16px; --shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } body { background-color: var(--bg-primary); color: var(--text-primary); min-height: 100vh; display: flex; overflow: hidden; } /* Основной контейнер */ .app-container { display: flex; width: 100vw; height: 100vh; } /* Сайдбар */ .sidebar { width: 320px; background-color: var(--bg-secondary); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; flex-shrink: 0; z-index: 10; } .sidebar-header { padding: 24px; border-bottom: 1px solid var(--border-color); } .logo { display: flex; align-items: center; gap: 12px; font-size: 1.25rem; font-weight: 700; color: var(--accent-blue); } .logo svg { width: 28px; height: 28px; fill: var(--accent-blue); } .progress-section { margin-top: 16px; background-color: var(--bg-primary); padding: 12px; border-radius: var(--radius-md); border: 1px solid var(--border-color); } .progress-info { display: flex; justify-content: space-between; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 8px; } .progress-bar-container { width: 100%; height: 8px; background-color: var(--bg-tertiary); border-radius: 4px; overflow: hidden; } .progress-bar-fill { height: 100%; width: 0%; background-color: var(--accent-green); transition: var(--transition); } /* Список уроков в сайдбаре */ .lessons-list { flex-grow: 1; overflow-y: auto; padding: 20px 16px; display: flex; flex-direction: column; gap: 8px; } .lesson-item { display: flex; align-items: center; padding: 12px 16px; border-radius: var(--radius-md); cursor: pointer; transition: var(--transition); border: 1px solid transparent; text-align: left; background: none; color: var(--text-primary); width: 100%; } .lesson-item:hover:not(.locked) { background-color: var(--bg-tertiary); transform: translateX(4px); } .lesson-item.active { background-color: var(--bg-tertiary); border-color: var(--accent-blue); } .lesson-item.locked { opacity: 0.5; cursor: not-allowed; } .lesson-item.completed .lesson-status-icon { color: var(--accent-green); fill: var(--accent-green); } .lesson-status-icon { margin-right: 12px; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); flex-shrink: 0; } .lesson-status-icon svg { width: 20px; height: 20px; } .lesson-title-text { font-size: 0.9rem; font-weight: 500; line-height: 1.3; } /* Достижения (Ачивки) в сайдбаре */ .achievements-section { padding: 20px 24px; border-top: 1px solid var(--border-color); background-color: rgba(15, 23, 42, 0.4); } .achievements-section h4 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); margin-bottom: 12px; } .achievements-grid { display: flex; gap: 8px; flex-wrap: wrap; } .achievement-badge { width: 42px; height: 42px; border-radius: 50%; background-color: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; cursor: pointer; border: 2px solid var(--border-color); transition: var(--transition); position: relative; } .achievement-badge.unlocked { border-color: var(--accent-amber); background-color: rgba(245, 158, 11, 0.15); } .achievement-badge.unlocked svg { fill: var(--accent-amber); color: var(--accent-amber); } .achievement-badge svg { width: 22px; height: 22px; color: var(--text-secondary); } /* Тултипы для ачивок */ .achievement-badge .tooltip { position: absolute; bottom: 125%; left: 50%; transform: translateX(-50%) translateY(10px); background-color: #020617; color: #fff; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 0.75rem; width: 200px; text-align: center; visibility: hidden; opacity: 0; transition: var(--transition); pointer-events: none; box-shadow: var(--shadow); border: 1px solid var(--border-color); z-index: 100; } .achievement-badge:hover .tooltip { visibility: visible; opacity: 1; transform: translateX(-50%) translateY(0); } /* Главная рабочая область */ .main-content { flex-grow: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 40px; max-width: 1200px; margin: 0 auto; width: 100%; } /* Хедер рабочей области */ .content-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; } .content-header h1 { font-size: 1.75rem; font-weight: 800; } .reset-btn { background: none; border: 1px solid var(--border-color); color: var(--text-secondary); padding: 8px 16px; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.85rem; transition: var(--transition); } .reset-btn:hover { border-color: var(--accent-red); color: var(--accent-red); background-color: rgba(239, 68, 68, 0.05); } /* Двухколоночный макет урока */ .lesson-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; align-items: start; flex-grow: 1; } @media (max-width: 1024px) { .lesson-grid { grid-template-columns: 1fr; } .sidebar { width: 260px; } } /* Панель Теории */ .theory-panel { background-color: var(--bg-secondary); border-radius: var(--radius-lg); border: 1px solid var(--border-color); padding: 32px; box-shadow: var(--shadow); height: 100%; display: flex; flex-direction: column; } .section-badge { background-color: rgba(56, 189, 248, 0.1); color: var(--accent-blue); padding: 6px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; align-self: flex-start; margin-bottom: 16px; } .panel-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 20px; } .theory-text { color: var(--text-secondary); line-height: 1.6; font-size: 0.95rem; overflow-y: auto; flex-grow: 1; } .theory-text h3 { color: var(--text-primary); font-size: 1.1rem; margin-top: 24px; margin-bottom: 12px; } .theory-text p { margin-bottom: 16px; } .theory-text ul, .theory-text ol { margin-bottom: 16px; padding-left: 20px; } .theory-text li { margin-bottom: 8px; } .code-block { background-color: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 14px; font-family: "Courier New", Courier, monospace; font-size: 0.85rem; color: #e2e8f0; overflow-x: auto; margin: 16px 0; white-space: pre-wrap; } /* Панель Кейс-Тренажера */ .case-panel { background-color: var(--bg-secondary); border-radius: var(--radius-lg); border: 1px solid var(--border-color); padding: 32px; box-shadow: var(--shadow); min-height: 500px; display: flex; flex-direction: column; } .case-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; } /* Индикатор шагов тренажера */ .step-indicator { display: flex; gap: 12px; width: 100%; } .step-dot { flex-grow: 1; height: 6px; border-radius: 3px; background-color: var(--bg-tertiary); position: relative; transition: var(--transition); } .step-dot.active { background-color: var(--accent-blue); } .step-dot.completed { background-color: var(--accent-green); } .step-dot-label { position: absolute; top: 12px; font-size: 0.7rem; color: var(--text-secondary); white-space: nowrap; } /* Контент симулятора */ .simulator-content { display: flex; flex-direction: column; flex-grow: 1; margin-top: 16px; } .intro-text { font-size: 1rem; line-height: 1.5; margin-bottom: 24px; background-color: rgba(56, 189, 248, 0.03); border-left: 4px solid var(--accent-blue); padding: 16px; border-radius: 0 var(--radius-md) var(--radius-md) 0; } .question-text { font-size: 1.1rem; font-weight: 600; margin-bottom: 20px; color: var(--text-primary); } /* Опции/Варианты ответов */ .options-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; } .option-button { background-color: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: var(--radius-md); padding: 16px; text-align: left; cursor: pointer; transition: var(--transition); font-size: 0.95rem; display: flex; gap: 12px; align-items: flex-start; } .option-button:hover:not(:disabled) { border-color: var(--accent-blue); background-color: rgba(56, 189, 248, 0.02); transform: translateY(-1px); } .option-button:disabled { cursor: not-allowed; opacity: 0.6; } .option-button.selected-correct { border-color: var(--accent-green); background-color: rgba(16, 185, 129, 0.05); } .option-button.selected-incorrect { border-color: var(--accent-red); background-color: rgba(239, 108, 108, 0.05); } .option-letter { width: 24px; height: 24px; background-color: var(--bg-tertiary); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); flex-shrink: 0; margin-top: 1px; } .option-button.selected-correct .option-letter { background-color: var(--accent-green); color: white; } .option-button.selected-incorrect .option-letter { background-color: var(--accent-red); color: white; } /* Секция фидбека (Инсайт / Диагноз / Итог) */ .feedback-box { background-color: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-top: auto; animation: fadeIn 0.3s ease-out; } .feedback-box.correct { border-left: 4px solid var(--accent-green); } .feedback-box.incorrect { border-left: 4px solid var(--accent-red); } .feedback-header { display: flex; align-items: center; gap: 8px; font-weight: 700; margin-bottom: 8px; font-size: 0.95rem; } .feedback-header.correct { color: var(--accent-green); } .feedback-header.incorrect { color: var(--accent-red); } .feedback-text { font-size: 0.9rem; line-height: 1.5; color: var(--text-secondary); } /* Кнопка действия */ .action-button { margin-top: 20px; background-color: var(--accent-blue); color: #0f172a; border: none; border-radius: var(--radius-md); padding: 14px 28px; font-weight: 700; cursor: pointer; transition: var(--transition); display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; } .action-button:hover { opacity: 0.9; transform: translateY(-1px); } /* Toast Уведомления */ .toast-container { position: fixed; top: 24px; right: 24px; display: flex; flex-direction: column; gap: 12px; z-index: 1000; } .toast { background-color: var(--bg-secondary); border: 1px solid var(--accent-amber); border-radius: var(--radius-md); box-shadow: var(--shadow); padding: 16px; display: flex; align-items: center; gap: 16px; width: 320px; transform: translateX(120%); transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); animation: slideIn 0.3s forwards; } @keyframes slideIn { to { transform: translateX(0); } } .toast-icon { width: 40px; height: 40px; border-radius: 50%; background-color: rgba(245, 158, 11, 0.15); display: flex; align-items: center; justify-content: center; color: var(--accent-amber); flex-shrink: 0; } .toast-icon svg { width: 24px; height: 24px; fill: var(--accent-amber); } .toast-body { display: flex; flex-direction: column; } .toast-title { font-weight: 700; font-size: 0.9rem; color: var(--accent-amber); } .toast-desc { font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px; } /* Кейс завершен - Конфетти и результат */ .case-completed-view { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; flex-grow: 1; padding: 40px 20px; } .success-illustration { width: 80px; height: 80px; background-color: rgba(16, 185, 129, 0.15); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 24px; } .success-illustration svg { width: 48px; height: 48px; color: var(--accent-green); } .success-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 12px; color: var(--accent-green); } .success-desc { color: var(--text-secondary); font-size: 0.95rem; line-height: 1.6; margin-bottom: 32px; max-width: 400px; } /* Вспомогательные классы */ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } ::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-track { background: var(--bg-primary); } ::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--border-color); } </style> </head> <body> <div class="app-container"> <!-- Сайдбар слева --> <aside class="sidebar"> <div class="sidebar-header"> <div class="logo"> <svg viewBox="0 0 24 24"> <path d="M12 3C7.58 3 4 4.79 4 7s3.58 4 8 4 8-1.79 8-4-3.58-4-8-4M4 9v4c0 2.21 3.58 4 8 4s8-1.79 8-4V9c0 2.21-3.58 4-8 4s-8-1.79-8-4m0 5v4c0 2.21 3.58 4 8 4s8-1.79 8-4v-4c0 2.21-3.58 4-8 4s-8-1.79-8-4z"/> </svg> <span>DB Optimus</span> </div> <div class="progress-section"> <div class="progress-info"> <span>Прогресс курса</span> <span id="progress-text">0 / 5</span> </div> <div class="progress-bar-container"> <div id="progress-bar" class="progress-bar-fill"></div> </div> </div> </div> <!-- Список уроков --> <nav class="lessons-list" id="lessons-list-container"> <!-- Генерируется динамически через JS --> </nav> <!-- Блок достижений --> <div class="achievements-section"> <h4>Достижения</h4> <div class="achievements-grid" id="achievements-grid-container"> <!-- Генерируется динамически через JS --> </div> </div> </aside> <!-- Главный контент справа --> <main class="main-content"> <header class="content-header"> <div> <span style="color: var(--text-secondary); font-size: 0.85rem;" id="lesson-number">Урок 1</span> <h1 id="lesson-title">Загрузка...</h1> </div> <button class="reset-btn" id="reset-progress-btn">Сбросить прогресс</button> </header> <!-- Двухпанельный макет --> <div class="lesson-grid"> <!-- Левая панель: Теория --> <section class="theory-panel"> <span class="section-badge">Теоретический базис</span> <h2 class="panel-title" id="theory-title">...</h2> <div class="theory-text" id="theory-content"> <!-- Теория генерируется через JS --> </div> </section> <!-- Правая панель: Тренажер (Машина состояний) --> <section class="case-panel" id="case-panel-container"> <!-- Шаги симулятора генерируются через JS --> </section> </div> </main> </div> <!-- Контейнер для тостов --> <div class="toast-container" id="toast-container"></div> <script> // ========================================== // 1. БАЗА ДАННЫХ КОНТЕНТА (TUTORIAL_DATA) // ========================================== const TUTORIAL_DATA = [ { id: 1, title: "Индексы и Full Table Scan", theoryTitle: "Природа B-Tree индексов", theoryHTML: ` <p>При поиске строки без индексов СУБД делает <strong>Full Table Scan (FTS)</strong> — считывает поочередно каждый блок таблицы с диска. Для миллионов строк это катастрофа.</p> <h3>Как помогает B-Tree индекс?</h3> <p><strong>B-Tree индекс</strong> строит сбалансированное дерево, сортируя ключевые поля. Поиск по нему имеет сложность <code>O(log N)</code> вместо <code>O(N)</code>. Вместо сканирования миллионов страниц СУБД считывает всего 3-4 страницы в глубину.</p> <div class="code-block">-- Обычный поиск (FTS): SELECT * FROM users WHERE email = 'test@mail.com'; -- Оптимизация (Создание индекса): CREATE INDEX idx_users_email ON users(email);</div> <h3>Композитные (составные) индексы</h3> <p>Если запрос фильтрует по нескольким полям, например, <code>WHERE first_name = 'Ivan' AND last_name = 'Petrov'</code>, одиночные индексы малоэффективны. Нужен <strong>композитный индекс</strong> на обе колонки:</p> <div class="code-block">CREATE INDEX idx_users_name ON users(last_name, first_name);</div> <p><em>Важно:</em> важен порядок полей в индексе! Индекс работает только при условии, что в запросе участвуют поля "слева направо" (правило левого префикса).</p> `, case: { intro: "В пятницу вечером крупный маркетплейс упал. Пользователи жалуются на бесконечную загрузку страницы 'История заказов'. Метрики бэкенда показывают резкий рост таймаутов от СУБД PostgreSQL.", step1: { question: "Шаг 1: С чего начнем первичную диагностику?", options: [ { text: "Анализировать логи Node.js серверов приложения.", result: "Логи забиты ошибками Gateway Timeout (504). Понятно, что база отвечает медленно, но причину в базе это не раскрывает.", isOptimal: false, insight: "Вы получили общие сведения о падении бэкенда, но до сих пор не знаете, почему именно тормозит СУБД." }, { text: "Проанализировать системные метрики сервера БД (CPU, I/O) в Grafana.", result: "Вы видите утилизацию CPU на уровне 99.8% и высокое дисковое чтение (Read I/O). Это указывает на то, что база выполняет крайне тяжелую работу по чтению данных.", isOptimal: true, insight: "CPU и диск перегружены. СУБД тратит все ресурсы на постоянное чтение огромного количества данных с диска и их обработку в памяти." }, { text: "Перезагрузить СУБД PostgreSQL в надежде, что ресурсы очистятся.", result: "Временная перезагрузка помогла на 30 секунд. Как только трафик вернулся, CPU снова взлетел до 100%. Настоящая проблема не решена.", isOptimal: false, insight: "Временный костыль. Вы узнали, что проблема перманентна и вызвана штатной нагрузкой, а не разовым сбоем." } ] }, step2: { question: "Шаг 2: Учитывая нагрузку на CPU/Диск, как локализовать проблемный запрос?", options: [ { text: "Активировать pg_stat_statements и выявить топ-1 запрос по суммарному времени выполнения.", result: "В топе оказался тяжелый запрос: SELECT * FROM orders WHERE status = 'COMPLETED' AND user_id = 9482. Он выполняется в среднем 2.8 секунды, а вызывается тысячи раз в минуту.", isOptimal: true, diagnosis: "Мы нашли медленный запрос к таблице orders. База тратит секунды на каждый такой SELECT." }, { text: "Запустить EXPLAIN (без ANALYZE) на случайную выборку запросов обновления корзины.", result: "Запросы UPDATE выполняются быстро, так как обновляют единичные записи по первичному ключу. Это ложный след.", isOptimal: false, diagnosis: "Операции записи в порядке. Мы зря потратили время, но исключили одну из гипотез." }, { text: "Посмотреть лог медленных запросов PostgreSQL и наугад выбрать самый длинный INSERT.", result: "INSERT запросы тоже пишутся быстро. Проблема не в записи новых строк, а в поиске старых.", isOptimal: false, diagnosis: "Запись работает стабильно. Проблема кроется в операциях чтения (SELECT)." } ] }, step3: { question: "Шаг 3: Выполнен EXPLAIN ANALYZE для 'SELECT * FROM orders WHERE status = 'COMPLETED' AND user_id = 9482'. План запроса показал 'Seq Scan on orders'. Как исправим?", options: [ { text: "Создать два отдельных индекса: CREATE INDEX idx_status ON orders(status) и CREATE INDEX idx_user ON orders(user_id).", result: "СУБД выберет только один индекс (скорее всего по user_id) или попытается сделать Bitmap Index Scan на их объединение. Это лучше, чем Seq Scan, но все равно медленнее оптимального варианта.", isOptimal: false, finalFeedback: "Решение частичное. Нагрузка снизилась, но одиночные индексы заставляют СУБД делать лишнюю работу по склейке результатов. Вы прошли урок, но в будущем стремитесь к более точным индексам!" }, { text: "Создать композитный индекс: CREATE INDEX idx_orders_user_status ON orders(user_id, status);", result: "Великолепно! Запрос мгновенно перешел на 'Index Scan'. Поиск по композитному индексу занимает менее 1 мс. СУБД сразу находит записи нужного пользователя с нужным статусом. Нагрузка на CPU упала до 5%.", isOptimal: true, finalFeedback: "Идеально! Композитный индекс полностью покрыл условия фильтрации WHERE. СУБД больше не сканирует всю таблицу." }, { text: "Выполнить денормализацию: перенести все завершенные заказы в отдельную таблицу 'archive_orders'.", result: "Это решит проблему временно, но усложнит логику приложения (придется писать в две таблицы) и потребует архивации по расписанию. Для исправления простого SELECT это слишком избыточно и сложно.", isOptimal: false, finalFeedback: "Слишком сложное архитектурное решение для проблемы, которая элегантно решается добавлением одной строчки индекса." } ] }, achievement: { id: "explain_master", title: "Повелитель EXPLAIN", desc: "Успешно побежден Full Table Scan с помощью оптимального композитного индекса!" } } }, { id: 2, title: "N+1 запросы и оптимизация JOIN", theoryTitle: "Проблема N+1 и сила JOIN", theoryHTML: ` <p>Типичная беда ORM (Hibernate, Prisma, SQLAlchemy) — генерация избыточных запросов. Вместо получения данных за один раз, приложение делает один стартовый запрос, а затем по одному дополнительному запросу для каждой строки.</p> <h3>Пример N+1 в коде</h3> <div class="code-block">// Сначала получаем 100 постов: const posts = db.query("SELECT * FROM posts LIMIT 100"); // Для каждого поста ORM лезет в БД за автором (100 дополнительных запросов!): for(let post of posts) { post.author = db.query("SELECT * FROM users WHERE id = ?", post.author_id); }</div> <p>Итого в базу летит 1 + 100 = 101 запрос вместо 1!</p> <h3>Как решать?</h3> <p>1. <strong>Eager Loading (Жадная загрузка):</strong> Использование JOIN на уровне SQL.</p> <div class="code-block">SELECT posts.*, users.name FROM posts INNER JOIN users ON posts.author_id = users.id LIMIT 100;</div> <p>2. <strong>Batch Loading:</strong> Выгрузка связанных сущностей пачкой через <code>IN (id1, id2, ...)</code>.</p> `, case: { intro: "Разработчики выкатили новую фичу — ленту новостей с комментариями и авторами. Сразу после релиза приложение упало по тайм-ауту соединения с БД, хотя на деве (где было всего 10 пользователей) всё работало отлично.", step1: { question: "Шаг 1: Первичная диагностика. Откуда такой шквал запросов?", options: [ { text: "Посмотреть сетевой дамп трафика между приложением и БД через Wireshark.", result: "Трафик забит миллионами мелких TCP-пакетов с одинаковыми запросами SELECT * FROM comments WHERE post_id = ... СУБД захлебывается от количества сетевых транзакций.", isOptimal: true, insight: "В БД летит невероятно много однотипных маленьких запросов чтения. Это классическая картина неоптимальной работы ORM." }, { text: "Проверить настройки пула соединений БД в файле конфигурации приложения.", result: "Размер пула равен 20. Это нормально. Но все 20 соединений намертво заняты и висят в режиме ожидания ответа СУБД.", isOptimal: false, insight: "Вы узнали, что пул забит, но почему СУБД обрабатывает запросы так долго — всё ещё неясно." }, { text: "Проанализировать использование оперативной памяти сервером базы данных.", result: "С памятью проблем нет, СУБД потребляет около 35% RAM. Проблема не в нехватке памяти.", isOptimal: false, insight: "Дефицита памяти нет, значит, СУБД тормозит из-за характера самих запросов, а не системных ограничений." } ] }, step2: { question: "Шаг 2: Вы поняли, что на сервер БД сыплются тонны мелких запросов к связанным таблицам. Как подтвердить проблему N+1?", options: [ { text: "Включить логирование всех SQL-запросов на стороне приложения в режиме разработки.", result: "Лог показывает: при переходе на главную страницу идет 1 запрос постов, а затем 50 запросов SELECT * FROM authors... и еще 50 SELECT * FROM comments... на каждый пост отдельно.", isOptimal: true, diagnosis: "Диагноз подтвержден: классическая ORM-проблема N+1. На каждый пост генерируются новые запросы." }, { text: "Посмотреть план выполнения запроса INSERT для добавления комментариев.", result: "План INSERT идеален, так как он использует индексы. Но на чтение ленты это никак не влияет.", isOptimal: false, diagnosis: "Проблема связана исключительно со сбором данных для отображения, а не с операциями добавления данных." }, { text: "Проверить индексы на первичных ключах (PRIMARY KEY) таблиц comments и posts.", result: "Индексы PK на месте (id). Поиск по ID работает моментально, но сама необходимость делать 100 отдельных запросов убивает производительность.", isOptimal: false, diagnosis: "Инфраструктура индексов в порядке. Причина тормозов — в оверхеде на 100+ сетевых раунд-трипов приложения к БД." } ] }, step3: { question: "Шаг 3: Как раз и навсевать решить проблему N+1 в данном коде получения ленты?", options: [ { text: "Настроить кэширование Redis для каждого отдельного автора и комментария.", result: "Это снизит нагрузку на БД, но первая загрузка (холодный старт) всё равно будет валить базу. Плюс появится сложная логика инвалидации кэша при обновлении комментариев.", isOptimal: false, finalFeedback: "Обходной путь. Кэш — это хорошо, но архитектурно правильнее сначала исправить сам кривой запрос." }, { text: "Переписать запрос на явное объединение: использовать JOIN на уровне БД или включить Eager Loading (Include в Prisma, JoinFetch в Hibernate).", result: "Блестяще! СУБД теперь делает всего один мощный запрос, мгновенно собирая посты, комментарии и авторов через JOIN. Сетевой оверхед исчез, страница грузится за 40 мс вместо 5 секунд!", isOptimal: true, finalFeedback: "Отличный выбор! Использование JOIN снизило количество запросов с N+1 до ровно 1. Это фундаментальное решение проблемы." }, { text: "Ограничить пагинацию до 2 элементов на страницу, чтобы снизить N до 2.", result: "Пользователи теперь видят всего по 2 поста на странице, что крайне неудобно для ленты новостей. Проблема замаскирована, но не решена.", isOptimal: false, finalFeedback: "Это ухудшило UX (пользовательский опыт) ради обхода технической проблемы. Не самое лучшее инженерное решение." } ] }, achievement: { id: "nplus1_hunter", title: "Охотник на N+1", desc: "Успешно ликвидировал лавину запросов, объединив выборку через JOIN!" } } }, { id: 3, title: "Блокировки и Взаимоблокировки", theoryTitle: "Конкуренция транзакций", theoryHTML: ` <p>Для соблюдения принципа ACID (Изоляция) СУБД блокирует строки или целые таблицы, когда одна транзакция модифицирует данные, а другая пытается их прочитать или изменить.</p> <h3>Виды блокировок:</h3> <ul> <li><strong>Shared Lock (Разделяемая, S):</strong> накладывается при чтении. Позволяет другим транзакциям читать, но запрещает изменять.</li> <li><strong>Exclusive Lock (Эксклюзивная, X):</strong> накладывается при записи. Запрещает и чтение, и запись другим транзакциям.</li> </ul> <h3>Deadlock (Взаимоблокировка)</h3> <p>Ситуация, когда Транзакция 1 заблокировала строку А и ждет строку Б. В это же время Транзакция 2 заблокировала строку Б и ждет строку А. Они будут ждать друг друга вечно, пока СУБД принудительно не прервет одну из них.</p> <div style="text-align: center; margin: 15px 0;"> <strong>Транзакция 1:</strong> UPDATE t SET val=1 WHERE id=1; <em style="color:var(--accent-red)">=> заблокировал ID 1</em><br> <strong>Транзакция 2:</strong> UPDATE t SET val=2 WHERE id=2; <em style="color:var(--accent-red)">=> заблокировал ID 2</em><br> <strong>Транзакция 1:</strong> UPDATE t SET val=3 WHERE id=2; <em style="color:var(--accent-amber)">=> ЖДЕТ Т2...</em><br> <strong>Транзакция 2:</strong> UPDATE t SET val=4 WHERE id=1; <em style="color:var(--accent-red)">=> DEADLOCK!</em> </div> `, case: { intro: "В интернет-магазине запущен розыгрыш супер-товара со скидкой 99%. В пиковый момент тысячи людей одновременно оформляют заказ. СУБД начинает выдавать ошибки: 'deadlock detected' или 'Lock wait timeout exceeded'. Заказы теряются.", step1: { question: "Шаг 1: Какое диагностическое действие поможет увидеть состояние блокировок в реальном времени?", options: [ { text: "Посмотреть загрузку CPU на сервере базы данных.", result: "Удивительно, но CPU загружен всего на 20%. Это потому, что потоки базы данных не выполняют вычисления, а просто 'спят' в ожидании освобождения замков.", isOptimal: false, insight: "Низкий CPU при зависании системы часто сигнализирует о том, что процессы стоят в очереди на блокировках." }, { text: "Сделать SELECT * FROM pg_locks (или SHOW ENGINE INNODB STATUS в MySQL).", result: "Вы видите десятки транзакций со статусом 'granted = false' (ожидание блокировки). Несколько процессов ждут освобождения одной и той же строки в таблице 'stocks' (остатки товаров).", isOptimal: true, insight: "Выявлен жесткий конфликт: множество транзакций пытаются одновременно заблокировать и обновить одну и ту же запись остатка товара." }, { text: "Проверить объем свободной оперативной памяти (RAM).", result: "RAM свободна на 60%. Дело точно не в памяти и не в диске.", isOptimal: false, insight: "Памяти хватает, значит, дело в логике параллельного доступа транзакций к ресурсам базы." } ] }, step2: { question: "Шаг 2: Вы выяснили, что транзакции зависают на обновлении строки остатка товара. Какая архитектурная ошибка приводит к взаимным блокировкам (Deadlocks)?", options: [ { text: "Транзакции обновляют таблицы 'orders' и 'stocks' в разном порядке (Т1 сначала orders потом stocks, а Т2 — наоборот).", result: "Да! Если Транзакция 1 блокирует заказ А и пытается обновить склад Б, а Транзакция 2 заблокировала склад Б и пытается обновить заказ А — СУБД фиксирует взаимный тупик (Deadlock).", isOptimal: true, diagnosis: "Установлен классический диагноз: асинхронный порядок обновления связанных таблиц в конкурирующих транзакциях." }, { text: "В базе данных не установлены внешние ключи (FOREIGN KEY) между таблицами.", result: "Отсутствие FK снижает целостность данных, но само по себе не вызывает взаимные блокировки. Напротив, наличие FK иногда усиливает каскадные блокировки.", isOptimal: false, diagnosis: "Отсутствие FK не является причиной взаимных дедлоков в данном случае." }, { text: "Индексы на таблице 'stocks' построены неправильно, из-за чего блокируется вся таблица вместо конкретной строки.", result: "При отсутствии индексов СУБД действительно может заблокировать всю таблицу (Table Lock) при UPDATE. Но в таблице 'stocks' поиск идет по первичному ключу product_id, блокировка точечная (Row Lock).", isOptimal: false, diagnosis: "Индексы по PK работают верно, блокировки происходят на уровне отдельных строк, а не всей таблицы." } ] }, step3: { question: "Шаг 3: Какое решение устранит дедлоки и разгрузит очередь блокировок при оформлении заказа?", options: [ { text: "Повысить уровень изоляции транзакций до SERIALIZABLE.", result: "Это сделает только хуже! SERIALIZABLE заставляет СУБД жестко следить за пересечением транзакций, количество ошибок 'cannot serialize access due to concurrent update' вырастет в сотни раз.", isOptimal: false, finalFeedback: "Повышение изоляции до SERIALIZABLE в высоконагруженных системах ведет к катастрофическому росту отказов транзакций." }, { text: "Внедрить строгий и одинаковый порядок обновления ресурсов во всех транзакциях кода (например, всегда сначала 'orders', затем 'stocks'). Плюс использовать SELECT ... FOR UPDATE с тайм-аутом.", result: "Потрясающий результат! Одинаковый порядок обновления гарантирует, что транзакции будут выстраиваться в аккуратную очередь, а не блокировать друг друга крест-накрест. Ошибки взаимных блокировок полностью исчезли!", isOptimal: true, finalFeedback: "Великолепно! Согласованный порядок доступа к ресурсам — фундаментальный закон предотвращения Deadlock-ов." }, { text: "Увеличить системный параметр 'lock_wait_timeout' до 120 секунд, чтобы транзакции ждали дольше.", result: "Пользователи стали ждать ответа по 2 минуты вместо быстрых ошибок. Это привело к полному исчерпанию пула соединений бэкенда. Приложение просто перестало отвечать всем.", isOptimal: false, finalFeedback: "Увеличение таймаута ожидания блокировки усугубляет проблему, накапливая гигантские очереди зависших запросов." } ] }, achievement: { id: "deadlock_master", title: "Разрушитель Тупиков", desc: "Успешно победил взаимные блокировки, наведя порядок в транзакциях!" } } }, { id: 4, title: "Пул соединений и ресурсы", theoryTitle: "Жизненный цикл соединений СУБД", theoryHTML: ` <p>Открытие нового физического TCP-соединения с базой данных — крайне дорогая операция. Она требует ресурсов процессора и времени на рукопожатие (handshake) и авторизацию.</p> <h3>Пул соединений (Connection Pool)</h3> <p>Вместо открытия соединения на каждый запрос, приложение создает пул (набор) уже открытых соединений. Когда приложению нужен запрос, оно берет соединение из пула, выполняет работу и сразу возвращает его обратно.</p> <div style="text-align: center; margin: 15px 0;"> <span style="color: var(--accent-blue)">Приложение</span> ← [Берет соединение] ← <strong>[ Пул Соединений (например, HikariCP) ]</strong> → [Возвращает] → <span style="color: var(--accent-green)">СУБД</span> </div> <h3>Типичные проблемы:</h3> <ol> <li><strong>Утечка соединений (Connection Leak):</strong> код берет соединение из пула, но из-за ошибки (или забытого <code>close()</code>) не возвращает его обратно. Пул пустеет, новые запросы зависают.</li> <li><strong>Слишком маленький пул:</strong> запросы выстраиваются в очередь.</li> <li><strong>Слишком большой пул:</strong> база тратит всю память на обслуживание тысяч холостых соединений.</li> </ol> `, case: { intro: "В разгар рабочего дня корпоративный портал стал отдавать пользователям ошибку 'Internal Server Error'. В логах бэкенда красуется: 'Connection is not available, request timed out after 30000ms'. При этом сама база данных чувствует себя прекрасно, нагрузка на нее минимальна.", step1: { question: "Шаг 1: Почему приложение не может достучаться до БД, если сама СУБД свободна?", options: [ { text: "СУБД заблокировала порт 5432 из-за атаки.", result: "Сетевой сканер показывает, что порт открыт и база принимает соединения от других сервисов. Дело не в блокировке порта.", isOptimal: false, insight: "Сеть и порт доступны. Проблема локализована внутри самого приложения или его пула соединений." }, { text: "Посмотреть метрики утилизации пула соединений приложения (Active vs Idle connections).", result: "Метрики показывают, что все 50 соединений пула имеют статус 'Active' (заняты) на протяжении нескольких часов, хотя активных пользователей на сайте почти нет.", isOptimal: true, insight: "Пул соединений приложения забит на 100% активными сессиями, которые почему-то никогда не освобождаются." }, { text: "Увеличить лимит максимальных подключений (max_connections) в конфиге PostgreSQL.", result: "Вы увеличили лимит в базе. Пул приложения сожрал новые слоты за 5 минут, и ошибка таймаута вернулась. Вы просто отсрочили падение.", isOptimal: false, insight: "Проблема не в лимитах СУБД. Приложение бесконечно поглощает доступные соединения и 'забывает' их вернуть." } ] }, step2: { question: "Шаг 2: Вы обнаружили утечку соединений (Connection Leak) в приложении. Какое действие поможет найти конкретное место утечки в коде?", options: [ { text: "Включить в настройках пула (например, HikariCP) опцию детектирования утечек: 'leakDetectionThreshold = 2000'.", result: "Через 2 секунды после запроса в логах бэкенда появилась точная трассировка стека (Stack Trace), указывающая на метод Отчета: 'ReportService.generateExcel()'.", isOptimal: true, diagnosis: "Точный диагноз! Метод генерации Excel-отчетов берет соединение из пула напрямую, но не закрывает его." }, { text: "Переписать весь бэкенд на другой язык программирования, где пулы работают иначе.", result: "Это займет полгода работы и не гарантирует, что в новом коде разработчик снова не совершит ту же ошибку с забытым закрытием соединения.", isOptimal: false, diagnosis: "Нереалистичное и чрезмерно дорогое решение мелкого бага в бизнес-логике." }, { text: "Запустить профайлер памяти приложения (например, JProfiler или Chrome DevTools для Node.js) на предмет поиска утечек RAM.", result: "Профайлер показал небольшой рост памяти, но это следствие зависших объектов отчетов, а не первопричина утечки ресурсов базы.", isOptimal: false, diagnosis: "Утечка памяти присутствует, но она вторична. Нам нужно отследить именно жизненный цикл соединений с БД." } ] }, step3: { question: "Шаг 3: Как гарантированно исправить утечку соединений в методе генерации Excel-отчетов?", options: [ { text: "Обернуть работу с соединением в блок try-with-resources (в Java) или использовать конструкцию try...finally для обязательного закрытия соединения в блоке finally.", result: "Идеальное решение! Теперь, даже если при формировании Excel-файла произойдет критическая ошибка, блок 'finally' гарантированно выполнится и вернет соединение обратно в пул. Система работает стабильно!", isOptimal: true, finalFeedback: "Браво! Использование try-finally или автоматического закрытия ресурсов — золотой стандарт работы с пулами." }, { text: "Написать cron-скрипт, который каждые 10 минут принудительно перезагружает бэкенд-сервер.", result: "Это 'костыль', вызывающий кратковременный даунтайм приложения для всех пользователей каждые 10 минут. Руководство будет недовольно.", isOptimal: false, finalFeedback: "Периодический рестарт сервера маскирует плохой код, но не решает проблему утечки ресурсов." }, { text: "Увеличить размер пула соединений приложения до 10 000.", result: "СУБД PostgreSQL упала из-за нехватки оперативной памяти, так как под каждое соединение выделяется отдельный процесс с выделенной рабочей областью (work_mem).", isOptimal: false, finalFeedback: "Слишком большой пул убивает производительность СУБД. Ресурсы сервера базы данных не бесконечны." } ] }, achievement: { id: "pool_guard", title: "Хранитель Пула", desc: "Успешно ликвидировал утечку соединений с помощью защитных блоков try-finally!" } } }, { id: 5, title: "Кэширование и денормализация", theoryTitle: "Борьба с тяжелыми расчетами", theoryHTML: ` <p>Иногда даже идеально проиндексированная реляционная база данных не способна быстро отдавать данные. Например, при расчете сложных аналитических дашбордов с миллионами записей или при миллионах запросов в секунду к главной странице.</p> <h3>Стратегия 1: Кэширование (Redis/Memcached)</h3> <p>Приложение сначала проверяет данные в сверхбыстрой памяти Redis. Если данных там нет (Cache Miss), оно делает запрос к СУБД, сохраняет результат в Redis с ограничением по времени (TTL) и отдает пользователю.</p> <h3>Стратегия 2: Денормализация и Материализованные представления</h3> <p><strong>Нормализация</strong> убирает дублирование данных. <strong>Денормализация</strong> намеренно нарушает правила нормализации ради скорости чтения. Мы можем сохранять предварительно посчитанные агрегаты.</p> <p><strong>Materialized View (Материализованное представление)</strong> — это сохраненный на диск физический результат тяжелого запроса. Мы можем быстро читать его, обновляя по расписанию:</p> <div class="code-block">CREATE MATERIALIZED VIEW mv_sales_summary AS SELECT product_id, SUM(amount) FROM sales GROUP BY product_id; -- Быстрое чтение: SELECT * FROM mv_sales_summary; -- Обновление данных (например, раз в час): REFRESH MATERIALIZED VIEW mv_sales_summary;</div> `, case: { intro: "На аналитическом портале генеральный директор открывает вкладку 'Итоги года'. Страница грузится 45 секунд, нагружая дисковую подсистему СУБД на полную мощность. Попытки оптимизировать индексы на таблице 'sales' (300 млн строк) не увенчались успехом из-за тяжелых агрегатных функций (SUM, AVG) с группировкой по всем филиалам.", step1: { question: "Шаг 1: Какое диагностическое исследование подтвердит, что узкое место именно в агрегациях, а не в диске?", options: [ { text: "Изучить план выполнения EXPLAIN ANALYZE для аналитического запроса.", result: "План показывает 'HashAggregate' и 'GroupAggregate' на миллионы строк. СУБД вынуждена прочитать всю таблицу и в памяти производить сортировку и суммирование.", isOptimal: true, insight: "СУБД тратит львиную долю времени не на поиск строк, а на математические расчеты агрегатов в оперативной памяти." }, { text: "Сравнить скорость работы запроса на SSD диске и на HDD диске.", result: "На SSD запрос выполняется за 45 секунд, на HDD за 3 минуты. Стало быстрее, но 45 секунд для генерального директора — всё ещё неприемлемо.", isOptimal: false, insight: "Аппаратное ускорение дает прирост, но не решает фундаментальную проблему тяжелого обсчета миллионов строк 'на лету'." }, { text: "Проверить сетевой пинг до сервера БД во время выполнения расчета.", result: "Пинг стабилен и равен 1 мс. Сеть тут совершенно ни при чем, задержка происходит внутри ядра СУБД.", isOptimal: false, insight: "Задержка не имеет отношения к сети, вычисления полностью блокируют поток выполнения внутри базы данных." } ] }, step2: { question: "Шаг 2: База задыхается от расчетов агрегатов SUM/AVG в реальном времени по миллионам записей. Какую стратегию выберем для оптимизации?", options: [ { text: "Перенести агрегацию данных на сторону frontend-клиента (загружать все 300 млн строк в браузер и считать JS-кодом).", result: "Браузер моментально завис и упал от нехватки памяти при попытке скачать 20 гигабайт сырых данных. Худшая идея года.", isOptimal: false, diagnosis: "Попытка переложить тяжелую серверную работу на тонкого клиента полностью провалилась." }, { text: "Внедрить механизм предварительного расчета — денормализацию через создание Материализованного Представления (Materialized View) в СУБД.", result: "Отличный выбор! Мы создаем таблицу-снимок, где данные уже сгруппированы и просуммированы. Вместо расчета 300 млн строк, СУБД будет мгновенно читать готовые 100 строк агрегатов.", isOptimal: true, diagnosis: "Установлен курс на денормализацию. Данные для генерального директора можно готовить заранее, так как ежесекундная точность в годовых отчетах не требуется." }, { text: "Каждую секунду делать полный бэкап базы данных и разворачивать его на отдельном сервере для отчетов.", result: "Создание бэкапа 300 млн строк каждую секунду окончательно убьет и основной, и резервный сервера СУБД. Это технический абсурд.", isOptimal: false, diagnosis: "Нереализуемое архитектурное решение, приводящее к моментальной перегрузке серверов." } ] }, step3: { question: "Шаг 3: Вы внедрили Materialized View. Запрос генерального директора стал выполняться за 2 мс! Но данные в отчете устарели на следующий день. Как организовать обновление данных?", options: [ { text: "Настроить фоновую задачу (например, через pg_cron в БД или Celery/cron в приложении) для REFRESH MATERIALIZED VIEW раз в сутки ночью.", result: "Идеально! Директор получает мгновенный отчет с актуальностью на вчерашний вечер. Нагрузка на базу минимальна, так как тяжелый расчет происходит всего раз в сутки в период минимальной активности пользователей.", isOptimal: true, finalFeedback: "Поздравляем! Вы успешно внедрили архитектуру отложенных вычислений и денормализации. Портал летает!" }, { text: "Обновлять представление (REFRESH MATERIALIZED VIEW) при каждом новом заказе любого клиента.", result: "База вернулась в исходное зависшее состояние, так как новые заказы сыплются каждую секунду, и представление постоянно пересчитывается, блокируя запись.", isOptimal: false, finalFeedback: "Частый пересчет тяжелых материализованных представлений сводит на нет всю пользу от их создания." }, { text: "Попросить генерального директора вручную вводить SQL-команду 'REFRESH...' в консоли перед просмотром.", result: "Директор отказался работать системным администратором и потребовал вернуть старый медленный отчет, но автоматизированный.", isOptimal: false, finalFeedback: "Перекладывать технические задачи обслуживания БД на бизнес-пользователей — крайне непрофессионально." } ] }, achievement: { id: "cache_lord", title: "Лорд Денормализации", desc: "Укротил терабайты данных, внедрив молниеносные материализованные представления!" } } } ]; // Достижения (ачивки), которые можно разблокировать const ACHIEVEMENTS_DATA = { explain_master: { title: "Повелитель EXPLAIN", desc: "Успешно побежден Full Table Scan с помощью оптимального композитного индекса!", icon: "🔍" }, nplus1_hunter: { title: "Охотник на N+1", desc: "Успешно ликвидировал лавину запросов, объединив выборку через JOIN!", icon: "🏹" }, deadlock_master: { title: "Разрушитель Тупиков", desc: "Успешно победил взаимные блокировки, наведя порядок в транзакциях!", icon: "🛡️" }, pool_guard: { title: "Хранитель Пула", desc: "Успешно ликвидировал утечку соединений с помощью защитных блоков try-finally!", icon: "🧪" }, cache_lord: { title: "Лорд Денормализации", desc: "Укротил терабайты данных, внедрив молниеносные материализованные представления!", icon: "⚡" } }; // ========================================== // 2. СОСТОЯНИЕ ПРИЛОЖЕНИЯ (appState) // ========================================== let appState = { currentLessonId: 1, // Текущий шаг симуляции для активного урока: // 0 - Вводная // 1 - Шаг 1 (Первичная диагностика) // 2 - Шаг 2 (Уточняющая диагностика) // 3 - Шаг 3 (Оптимизация / Действие) // 4 - Кейс успешно завершен (Финальный экран) currentStep: 0, completedLessons: [], // Массив ID пройденных уроков unlockedAchievements: [], // Массив ID разблокированных ачивок selectedOptionIndex: null, // Индекс выбранного на текущем шаге ответа stepHistory: {} // История выборов на каждом шаге {step1: index, step2: index, step3: index} }; // ========================================== // 3. ИНИЦИАЛИЗАЦИЯ И СИНХРОНИЗАЦИЯ С LOCALSTORAGE // ========================================== function initApp() { // Пытаемся загрузить состояние из localStorage const savedState = localStorage.getItem('db_optimus_state'); if (savedState) { try { appState = JSON.parse(savedState); } catch (e) { console.error("Ошибка парсинга сохраненного состояния", e); } } setupEventListeners(); renderSidebar(); renderCurrentLesson(); } function saveState() { localStorage.setItem('db_optimus_state', JSON.stringify(appState)); } function setupEventListeners() { document.getElementById('reset-progress-btn').addEventListener('click', resetProgress); } // Сбросить прогресс обучения function resetProgress() { if (confirm("Вы уверены, что хотите сбросить весь прогресс обучения и полученные достижения?")) { localStorage.removeItem('db_optimus_state'); appState = { currentLessonId: 1, currentStep: 0, completedLessons: [], unlockedAchievements: [], selectedOptionIndex: null, stepHistory: {} }; saveState(); renderSidebar(); renderCurrentLesson(); showToast("Прогресс сброшен", "Вы можете начать обучение заново.", "🔄"); } } // ========================================== // 4. РЕНДЕРИНГ КОМПОНЕНТОВ // ========================================== // Рендер сайдбара (Список уроков и Ачивок) function renderSidebar() { // Обновляем прогресс-бар const totalLessons = TUTORIAL_DATA.length; const completedCount = appState.completedLessons.length; const progressPercent = (completedCount / totalLessons) * 100; document.getElementById('progress-text').innerText = `${completedCount} / ${totalLessons}`; document.getElementById('progress-bar').style.width = `${progressPercent}%`; // Рендер списка уроков const lessonsContainer = document.getElementById('lessons-list-container'); lessonsContainer.innerHTML = ''; TUTORIAL_DATA.forEach((lesson, index) => { const isCompleted = appState.completedLessons.includes(lesson.id); // Урок доступен, если он первый, или если предыдущий уже пройден const isLocked = index > 0 && !appState.completedLessons.includes(TUTORIAL_DATA[index - 1].id); const isActive = appState.currentLessonId === lesson.id; const lessonBtn = document.createElement('button'); lessonBtn.className = `lesson-item ${isActive ? 'active' : ''} ${isLocked ? 'locked' : ''} ${isCompleted ? 'completed' : ''}`; lessonBtn.disabled = isLocked; // SVG иконка статуса let iconSVG = ` <svg viewBox="0 0 24 24"> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9v-2h2v2zm0-4H9V7h2v5z"/> </svg> `; // Восклицательный знак по дефолту if (isCompleted) { iconSVG = ` <svg viewBox="0 0 24 24"> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/> </svg> `; // Галочка для пройденных } else if (isLocked) { iconSVG = ` <svg viewBox="0 0 24 24"> <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/> </svg> `; // Замок для закрытых } lessonBtn.innerHTML = ` <div class="lesson-status-icon">${iconSVG}</div> <span class="lesson-title-text">${index + 1}. ${lesson.title}</span> `; if (!isLocked) { lessonBtn.addEventListener('click', () => { selectLesson(lesson.id); }); } lessonsContainer.appendChild(lessonBtn); }); // Рендер ачивок const achievementsContainer = document.getElementById('achievements-grid-container'); achievementsContainer.innerHTML = ''; Object.keys(ACHIEVEMENTS_DATA).forEach(key => { const ach = ACHIEVEMENTS_DATA[key]; const isUnlocked = appState.unlockedAchievements.includes(key); const achBadge = document.createElement('div'); achBadge.className = `achievement-badge ${isUnlocked ? 'unlocked' : ''}`; achBadge.innerHTML = ` <span>${ach.icon}</span> <div class="tooltip"> <strong>${ach.title}</strong><br> ${ach.desc} ${!isUnlocked ? '<br><span style="color:var(--accent-amber); font-weight:600; font-size:10px;">[Заблокировано]</span>' : ''} </div> `; achievementsContainer.appendChild(achBadge); }); } // Выбор урока из меню function selectLesson(lessonId) { if (appState.currentLessonId !== lessonId) { appState.currentLessonId = lessonId; appState.currentStep = 0; // Сбрасываем симулятор на вводную appState.selectedOptionIndex = null; appState.stepHistory = {}; saveState(); renderSidebar(); renderCurrentLesson(); } } // Рендеринг текущего активного урока function renderCurrentLesson() { const lesson = TUTORIAL_DATA.find(l => l.id === appState.currentLessonId); if (!lesson) return; // Заполняем текстовые поля document.getElementById('lesson-number').innerText = `Урок ${lesson.id}`; document.getElementById('lesson-title').innerText = lesson.title; document.getElementById('theory-title').innerText = lesson.theoryTitle; document.getElementById('theory-content').innerHTML = lesson.theoryHTML; // Рендерим правый блок симулятора renderSimulatorPanel(lesson); } // Рендеринг панели тренажера в зависимости от текущего шага function renderSimulatorPanel(lesson) { const container = document.getElementById('case-panel-container'); container.innerHTML = ''; // 1. Создаем шапку панели с прогресс-точками const caseHeader = document.createElement('div'); caseHeader.className = 'case-header'; const stepIndicator = document.createElement('div'); stepIndicator.className = 'step-indicator'; const stepsLabels = ["Вводная", "Диагностика 1", "Диагностика 2", "Оптимизация"]; for (let i = 0; i < 4; i++) { const dot = document.createElement('div'); dot.className = `step-dot ${appState.currentStep === i ? 'active' : ''} ${appState.currentStep > i ? 'completed' : ''}`; const label = document.createElement('span'); label.className = 'step-dot-label'; label.innerText = stepsLabels[i]; dot.appendChild(label); stepIndicator.appendChild(dot); } caseHeader.appendChild(stepIndicator); container.appendChild(caseHeader); // 2. Рендерим контент в зависимости от шага машины состояний const simContent = document.createElement('div'); simContent.className = 'simulator-content'; if (appState.currentStep === 0) { // Шаг 0: Вводная simContent.innerHTML = ` <h3 class="panel-title" style="margin-bottom: 12px; color: var(--accent-blue)">Ситуация на проекте</h3> <div class="intro-text">${lesson.case.intro}</div> <button class="action-button" id="start-case-btn"> Принять вызов и начать диагностику <svg style="width:20px; height:20px; fill:currentColor;" viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg> </button> `; container.appendChild(simContent); document.getElementById('start-case-btn').addEventListener('click', () => { appState.currentStep = 1; appState.selectedOptionIndex = null; saveState(); renderSimulatorPanel(lesson); }); } else if (appState.currentStep === 1) { // Шаг 1: Первичная диагностика renderStep(lesson, simContent, lesson.case.step1, 1); } else if (appState.currentStep === 2) { // Шаг 2: Уточняющая диагностика renderStep(lesson, simContent, lesson.case.step2, 2); } else if (appState.currentStep === 3) { // Шаг 3: Действие по оптимизации renderStep(lesson, simContent, lesson.case.step3, 3); } else if (appState.currentStep === 4) { // Шаг 4: Кейс успешно завершен! renderCompletionScreen(lesson, container); } } // Общая функция рендеринга интерактивных шагов 1, 2 и 3 function renderStep(lesson, container, stepData, stepNumber) { const hasChosen = appState.selectedOptionIndex !== null; let introHtml = ''; // Если мы на Шаге 2 или 3, выводим исторический инсайт/диагноз для контекста if (stepNumber === 2 && appState.stepHistory.step1 !== undefined) { const prevChoice = lesson.case.step1.options[appState.stepHistory.step1]; introHtml = `<div class="intro-text" style="font-size:0.85rem; margin-bottom:16px;"><strong>Ранее выяснено:</strong> ${prevChoice.insight}</div>`; } else if (stepNumber === 3 && appState.stepHistory.step2 !== undefined) { const prevChoice = lesson.case.step2.options[appState.stepHistory.step2]; introHtml = `<div class="intro-text" style="font-size:0.85rem; margin-bottom:16px;"><strong>Установленный диагноз:</strong> ${prevChoice.diagnosis}</div>`; } container.innerHTML = ` ${introHtml} <div class="question-text">${stepData.question}</div> <div class="options-list" id="options-list"></div> `; const optionsList = container.querySelector('#options-list'); stepData.options.forEach((opt, idx) => { const btn = document.createElement('button'); btn.className = 'option-button'; // Буквенное обозначение вариантов const letter = String.fromCharCode(65 + idx); // A, B, C btn.innerHTML = ` <span class="option-letter">${letter}</span> <span style="flex-grow: 1;">${opt.text}</span> `; if (hasChosen) { btn.disabled = true; if (idx === appState.selectedOptionIndex) { btn.classList.add(opt.isOptimal ? 'selected-correct' : 'selected-incorrect'); } } else { btn.addEventListener('click', () => { makeChoice(lesson, stepData, idx, stepNumber); }); } optionsList.appendChild(btn); }); // Если выбор сделан, показываем блок фидбека и кнопку перехода if (hasChosen) { const chosenOpt = stepData.options[appState.selectedOptionIndex]; const feedbackBox = document.createElement('div'); feedbackBox.className = `feedback-box ${chosenOpt.isOptimal ? 'correct' : 'incorrect'}`; let title = chosenOpt.isOptimal ? "Отличное решение!" : "Неоптимальное решение или ошибка"; let textBody = chosenOpt.result; // Добавляем специфичные блоки Инсайта/Диагноза/Итогов if (stepNumber === 1) { textBody += `<br><br><strong>Полученный инсайт:</strong> ${chosenOpt.insight}`; } else if (stepNumber === 2) { textBody += `<br><br><strong>Установленный диагноз:</strong> ${chosenOpt.diagnosis}`; } else if (stepNumber === 3) { textBody += `<br><br><strong>Итоги оптимизации:</strong> ${chosenOpt.finalFeedback}`; } feedbackBox.innerHTML = ` <div class="feedback-header ${chosenOpt.isOptimal ? 'correct' : 'incorrect'}"> ${chosenOpt.isOptimal ? ` <svg style="width:20px; height:20px; fill:currentColor;" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg> ` : ` <svg style="width:20px; height:20px; fill:currentColor;" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg> `} <span>${title}</span> </div> <div class="feedback-text">${textBody}</div> `; container.appendChild(feedbackBox); // Кнопка перехода к следующему шагу const nextBtn = document.createElement('button'); nextBtn.className = 'action-button'; if (stepNumber < 3) { nextBtn.innerText = "Перейти к следующему шагу"; nextBtn.addEventListener('click', () => { appState.currentStep = stepNumber + 1; appState.selectedOptionIndex = null; saveState(); renderSimulatorPanel(lesson); }); } else { nextBtn.innerText = "Завершить разбор кейса"; nextBtn.addEventListener('click', () => { completeLesson(lesson); }); } container.appendChild(nextBtn); } const casePanel = document.getElementById('case-panel-container'); casePanel.appendChild(container); } // Обработка выбора ответа студентом function makeChoice(lesson, stepData, optionIndex, stepNumber) { appState.selectedOptionIndex = optionIndex; // Сохраняем в историю const stepKey = `step${stepNumber}`; appState.stepHistory[stepKey] = optionIndex; saveState(); renderSimulatorPanel(lesson); } // Завершение урока и расчет результатов/ачивок function completeLesson(lesson) { // Отмечаем урок как пройденный, если его еще нет в списке if (!appState.completedLessons.includes(lesson.id)) { appState.completedLessons.push(lesson.id); } // Проверяем, выполнил ли студент все шаги оптимально для получения ачивки // Оптимальное прохождение: на каждом шаге выбран оптимальный (isOptimal = true) ответ const s1Optimal = lesson.case.step1.options[appState.stepHistory.step1]?.isOptimal; const s2Optimal = lesson.case.step2.options[appState.stepHistory.step2]?.isOptimal; const s3Optimal = lesson.case.step3.options[appState.stepHistory.step3]?.isOptimal; let gotAchievement = false; const achId = lesson.case.achievement.id; if (s1Optimal && s2Optimal && s3Optimal) { if (!appState.unlockedAchievements.includes(achId)) { appState.unlockedAchievements.push(achId); gotAchievement = true; } } appState.currentStep = 4; // Переводим в состояние завершенности appState.selectedOptionIndex = null; saveState(); renderSidebar(); renderCurrentLesson(); if (gotAchievement) { const ach = ACHIEVEMENTS_DATA[achId]; showToast(ach.title, ach.desc, ach.icon); } } // Экран финального завершения кейса function renderCompletionScreen(lesson, container) { const isAllOptimal = lesson.case.step1.options[appState.stepHistory.step1]?.isOptimal && lesson.case.step2.options[appState.stepHistory.step2]?.isOptimal && lesson.case.step3.options[appState.stepHistory.step3]?.isOptimal; const nextLessonExists = TUTORIAL_DATA.some(l => l.id === lesson.id + 1); const completionHTML = ` <div class="case-completed-view"> <div class="success-illustration"> <svg viewBox="0 0 24 24"> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> </svg> </div> <h3 class="success-title">Кейс успешно разобран!</h3> <p class="success-desc"> ${isAllOptimal ? `Вы блестяще справились с диагностикой и оптимизацией! Все шаги были выполнены максимально эффективно. Разблокировано достижение: <strong>«${lesson.case.achievement.title}»</strong>.` : "Вы завершили практический кейс и изучили влияние различных решений на производительность БД. Попробуйте пройти его повторно или при идеальном выборе разблокируйте специальную ачивку!"} </p> <div style="display:flex; gap:16px; width:100%;"> <button class="action-button" style="background-color: var(--bg-tertiary); color: var(--text-primary);" id="retry-case-btn"> Повторить кейс </button> ${nextLessonExists ? ` <button class="action-button" id="next-lesson-btn"> Следующий урок <svg style="width:20px; height:20px; fill:currentColor;" viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg> </button> ` : ` <button class="action-button" style="background-color: var(--accent-green); color: #020617;" id="finish-course-btn"> 🎉 Курс завершен! </button> `} </div> </div> `; const viewContainer = document.createElement('div'); viewContainer.className = 'simulator-content'; viewContainer.innerHTML = completionHTML; container.appendChild(viewContainer); // Обработчики кнопок document.getElementById('retry-case-btn').addEventListener('click', () => { appState.currentStep = 0; appState.selectedOptionIndex = null; appState.stepHistory = {}; saveState(); renderSimulatorPanel(lesson); }); if (nextLessonExists) { document.getElementById('next-lesson-btn').addEventListener('click', () => { selectLesson(lesson.id + 1); }); } else { document.getElementById('finish-course-btn').addEventListener('click', () => { showToast("Поздравляем!", "Вы успешно освоили все 5 уроков по оптимизации баз данных!", "🏆"); }); } } // ========================================== // 5. ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ (TOASTS) // ========================================== function showToast(title, desc, icon = "🏆") { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = 'toast'; toast.innerHTML = ` <div class="toast-icon"> <span style="font-size: 20px;">${icon}</span> </div> <div class="toast-body"> <div class="toast-title">${title}</div> <div class="toast-desc">${desc}</div> </div> `; container.appendChild(toast); // Автоматическое удаление через 5 секунд setTimeout(() => { toast.style.animation = 'slideIn 0.3s reverse forwards'; setTimeout(() => { toast.remove(); }, 300); }, 5000); } // Запуск приложения window.onload = initApp; </script> </body> </html>