Текст
                    О

0 •©

Алгоритмы ,4
на языке 0

Артем Михайлов Алгоритмы на языке Санкт-Петербург «БХВ-Петербург» 2026
УДК 004 43 ББК 32.973.26-018.1 М69 Михайлов А. М. М69 Алгоритмы на языке Go. — СПб.: БХВ-Петербург, 2026. — 192 с., ил ISBN 978-5-9775-2148-2 Первая специализированная книга для алгоритмической подголовки с реализацией этих атгори гмов на языке Go. Содержит необходимые знания по испо.гьзуемым в Go структурам данных и идиомам, рассматривает алгоритмы поиска, сортировки, сжатия данных, различные аспекты обслуживания распределенных систем и недопущения конфликтов в их работе, а также криптографические алгоритмы и работу с цифровыми подписями Книга рассчитана на разработчиков среднего уровня, а также читателей, уже знакомых с базовыми возможностями Go. Для Go-разработчиков УДК 004.43 ЬБК 32 973.26-018.1 Группа подготовки издания: Руководитель проекта Зав редакцией Редактор Компьютерная верстка Корректор Оформление обложки Олег Сивченко Людмила Гауль Григорий Добин Натальи Смирновой Людмила Минина Зои Канторович Подписа нс в печать 09.04.26 Форма! 70x1007i„. Печэто офсетная. Усл. печ. л. 15 46. Тиоаж 1 ОСО экз. Заказ Nr 17627 "БХВ-Петербург", 191036, Санкт-Петербург, Гончарная ул., 20 Отпечатано с готового оригинал-макета ООС 'Принт-М" 142300 М О , г Чехов ул. Полиграфистов д 1 ISBN 978-5-9775-2148-2 © ООО "БХВ2026 © Оформление ООО "БХВ-Петербург", 2026
Оглавление Предисловие.............................................................. 7 - Глава 1 - Алгоритмы, сложности и структуры данных....................................9 Структуры данных: массивы, списки, хеш-таблипы, деревья....................11 Массивы...................................................... ....... 11 Связные списки. ......................................................13 Хеш-таблицы..................................... . . ____ _____ 14 Деревья.. . ......... ............... .............. ............ 15 С труктуры данных в Go................................................... 18 Массивы и срезы в Go................................................ 18 Связные списки в Со............................ ... ...........20 Хеш-таблицы (отображения) в Go ..................................... 22 Деревья в Go.............................................. 24 - Глава 2 - Поиск, сорт ировка и сжатие данных........................................28 Алгоритмы поиска....................... .. .............................. 28 Двоичный поиск............................... . .....................28 Интерполяционный поиск............................................ 30 Поиск Фибоначчи.................................................... 31 Алгоритмы сортировки..................................................... 33 Быстрая сортировка....................................................34 Сортировка слиянием.................................................. 36 Пирамидальная сортировка..............................................37 Поразрядная сортировка.............................................. 39 Алгоритмы сжатия данных_________________________________________________ 42 Алгоритм Хаффмана............................................ ...42 Алгоритм LZW.................................................... 45 Алгоритм Brotli..................................................... 48 Алгоритм Snappy.................................................... 50 Алгоритмы поиска подстроки............................................... 52 Алгоритм Кнута-Морриса-Пратга......................................... 52 Алгори гм Бойера-Мура..................................... .54 Алгоритм Рабина-Карпа________....................................... 57 Атгоритмы кратчайших путей....... ...................................... 59 Атгоритм Дейкстры.................................................. 60 Алгоритм Беллмана-Форда............................................... 65 Алгоритм А*: эвристический поиск................................... . 68 Примеры использования алгоритмов кратчайшего пути................. 72 Сравнение алгоритмов кратчайших путей............................... 73
Потоки в сетях........................................................ 73 Сети и потоки........................................................... 73 Задача о максимальном потоке: найти поток наибольшей величины.......... 74 Алгоритм Форда-Фалкериоча.............................................. 74 Алгоритм Диница..........................................................76 Применения. .. ................................................... 79 Теорема о максимальном потоке и минимальном разрезе......................79 - Глава 3- Распределенные алгоритмы.....................................................81 Что делает распределенные системы сложными0............................... 82 САР-тгорема: фундаментальный компромисс......................................83 Три свойства............................................... 84 Теорема................................................................ 84 Почему нельзя всё сразу?.................................................84 А что с СА?......................................................... 85 Примеры систем..................................................... 85 Критика и уточнения.................................................... 86 Модели согласованности...................................... 86 Строгая согласованность (Strong Consistency / Linearizability)..... 86 Последовательная согласованность (Sequential Consistency)...........87 Причинная согласованность (Causal Consistency) .....................87 Read Your Writes (прочитайте ваши записи)...........................87 Eventual Consistency (согласованность в конечном счете)........... 88 Eventual Consistency................................... ........ ............. . 88 Разрешение конфли ктов.............................................. 90 Кворумы: настраиваемая согласованность...................................93 Окно несогласован! гости.......................................... 94 Практические паттерны....... . _____ _ . . .... .. 94 Saga — для распределенных транзакций.....................................94 Outbox — для надежной публикации событий.................................96 Idempotency key — для идемпотентных операций ......................... . ..97 Circuit breaker — для устойчивости.................................... 98 - Глава 4- Алгоритмы консенсуса........................................................101 Что такие консенсус и зачем он нужен?.......................................101 Формальное определение . ....... ................. ....102 Практические применения.....................................................102 Невозможность: почему это так сложно?.................. . ......... .... . 103 Проблема двух генералов.................................................103 Почему это невозможно?........................................... 103 FLP-невозможность....... ... ..........................................104 Интуиция доказательства................................ 105 Как жить с невозможностью7.........................................105 Paxos: алгоритм, изменивший всё......................................... 106 Одна инстанция Paxos...................................... .....106
Номера предложений (proposal numbers).......................... 107 Протокол: две фазы.......................................... 107 Почему это работает?.................... . ...... ... ............ ,110 Пример выполнения . . .............. .... ...............110 Сценарий 1: простой случай..........................................110 Сценарий 2: конкурирующие proposers............................. .111 Проблема прогресса: дуэль proposers................ ............................... ИЗ Multi-Paxos от одного значения к журналу.............................. 114 Практические соображения.......................................... ..115 Недостат ки Paxos..................................................... .116 Raft: консенсус для смертных.......................................... 116 Философия Raft.................................................. 116 Роли и гермы.......................................................... 117 Выбор лидера ............................................................118 Репликация журнала .... ................ ...... 120 AppendEntries RPC................................................ 120 Алгоритм follower'a............................................... 121 Алгоритм лидера............................................. 122 Log Matching Property: свойство сопоставления с журналом .............. 124 Leader Completeness Property: свойство безопасности.................. 124 Почему' лидер не коммитит записи прошлых термов напрямую?............. 125 Кластерное членство Joint Consensus.................................... 125 Снэпшоты и компактификация журнала. .. ............................126 Клиентское взаимодействие........................................ 127 Полный пример: минимальный Raft на Go...................................127 Системы на Raft........................................................ 133 Заключение. ........................................ . .... ...... ...133 -ГТАН15 Распределенные транзакции...................................................134 ACID в распределенном мире..................................................134 Проблема атомарного коммига................................................ 134 Two-Phase Commit (2РС)..................................................135 Фаза 1: Prepare (голосование)......................................135 Фаза 2. Commit/Abort (фиксация)................................. ... 135 Проблемы 2РС..................................................... 136 Пример: перевод денег..............................................137 Three-Phase С ommit (ЗРС)............................................. 137 Saga............................................................ 138 Оркес грация.......................................................139 Хореография................................................. 140 ТСС: Try-Confirm-Cancel................................................ 140 Outbox Pattern: надежная публикация событий.................................141 Таблица outbox..........................................................142 Идемпотентность............... 142 Сравним подходы.......................................................... 143 Когда что использовать'’........ .. . . .143
- Глава 6 - Криптографические алгоритмы................................................144 От Цезаря до Тьюринга: краткая история шифров.......................... 144 Фундаментальные понятия. ......................................... 146 Симметричное шифрование........ ................................... ....147 Одноразовый блокнот: идеал, недостижимый на практике .... ... ........ 14S Потоковые и блочные шифры...................................... ..149 Реализация AES на Go................................................. 151 Асимметричное шифрование ................................................ 153 Хеш-функции: MD5, SHA-256. Blake2, Argon2............................. 157 Исторический пуль: от CRC до Argon2....................................158 Односторонность, коллизии и эффект лавины............................. 160 Зачем нужны разные хеш-функции?....................................... 161 MD5 — эпоха надежд и коллизий....... ............................... 163 SHA-256 — надежный наследник..................................... 164 В1аке2 — быстрее, сильнее, современнее................................ 167 Argon2 — хеширование паролей на стероидах.. .......................... 169 Выбор хеш-функции.................................................... 171 - Глава 7 - Реализация цифровых подписей и протоколов безопасности.....................174 Математ ическая сущность цифровых подписей.................................174 Схемы подписей на эллиптических кривых.....................................175 Реализация Ed25519 в Go.............................................. ..177 - Глава 8 - Атаки на цифровые подписи..................................................178 Replay-атаки и временные метки.............................................178 Подмена контекста (context substitution)...................................178 Слепые подписи и их применение ..................................... 179 Пороговые подписи..........................................................179 BLS-подписи и агрегация............................ ................ 180 JWT и stateless-аутентификация........................................ .. 181 Идея: зашифровать сессию в т окене................................... 181 Анатомия JWT: гри части................................................182 Жизненный цикл JWT............................................... ..182 Симметричная и асимметричная подпись................................. 184 Распространение публичного ключа JWKS ............................... 185 Теперь про уязвимости..................................................... 186 Атака Algorithm substitution...................................... 186 Уязвимость None algorithm...............................................187 Слабые секреты НМАС.... ............................................. 187 Атаки JKUhX5U..........................................................188 Предметный указатель.......................................................189
Предисловие В 2007 году в недрах Google несколько инженеров устало наблюдали, как их ог- ромный проект на C++ компилируется вечность. Вместо того чтобы впустую коро- тать время, они задумались: а что, если создать язык программирования, который избавит от столь долгих ожиданий0 Так родилась идея — придумать новый язык, где код пишется просто, а работает быстро. Экспериментальным плодом этой идеи стал язык Go. Его создатели — Роб Пайк, Кен Гомпсон и Роберт 1 ризмер — на досуге спроектировали Go, стремясь решить накопившиеся проблемы других языков. Они хотели, чтобы Go сочетал скорость и надежность «серьезных» системных языков с простотой и гибкостью скриптовых. Проще говоря, код на Go должен был работать почти так же быстро, как на С или Java, а писаться столь же легко, как на Python. Отчасти им это удалось — Go полу- чился минималистичным, но мощным и отлично подходящим для современных задач программирования. У каждого языка, конечно, свой характер. Pjthon— лаконичный и понятный, но за удобство порой расплачивается скоростью выполнения. Java, напротив, работает шустро, однако требует куда больше кода и терпения от программиста. Go старает- ся быть золотой серединой: профаммы на Go бегут почти с java-скоростью, а пи- шутся почти с python-простотой. Однако каким бы ни был язык, эффективность программы во многом определяется выбранными алгоритмами. В эпоху обилия фреймворков легко забыть, что в основе любой задачи лежит поиск оптимального решения. Алгоритмы — это как рецепты: хороший рецепт творит чудеса, позволяя даже скромному коду обогнать сырой и неоптимизированный вариант, запущенный на самом мощном сервере. Причем идеи алгоритмов универсальны и не зависят от языка программирования — разо- бравшись в них на Go, вы сможете с тем же успехом применять их и на Python, и на Java, и на любом другом языке Недаром алгоритмы и структуры данных называют сердцем программирования — без них не обойтись ни одному разработчику. Эта книга как раз о том, как соединить теорию алгоритмов с практикой программи- рования на Go. Мы будем изучать алгоритмы не только в теории, но и на практике. Книга рассчитана на разработчиков среднего уровня, а также студентов, уже зна- комых с базовыми возможностями Go. Предполагается, что вы написали на (io хотя бы пару простых программ и понимаете синтаксис языка. Но даже если вы чувст- вуете, что знания немного подзаржавели, не волнуйтесь — вначале мы кратко ос-
вежим основы Go (нужные нам); пройдемся по синтаксису, основным типам дан- ных и ключевым особенностям языка, чтобы вы уверенно чувствовали себя перед погружением в алгоритмы. После вводного обзора языка мы перейдем к самому интересному — к алгоритмам. Нас ждет путешествие от простого к сложному — начнем с базовых примеров (та- ких как сортировка и поиск), затем рассмотрим структуры данных и постепенно доберемся до более интересных тем: алгоритмов на 1рафах, элементов динамиче- ского программирования, а также параллельных вычислений с использованием го- ру тин. Шаг за шагом вы будете видеть, почему один алгоритм работает быстрее или надежнее другого и как выбирать подходящий под вашу задачу.
- Глава 1 - Алгоритмы, сложности и структуры данных Алгоритм в лротраммировании — это четкая последовательность шагов для реше- ния поставленной задачи. При сравнении алгоритмов важно учитывать их эффек- тивность, то есть сколько ресурсов (времени и памяти) они потребляют. Чтобы формально описывать эффективность, используют понятие асимптотической слож- ности алгоритмов. Асимптотический анализ рассматривает, как будет расти время работы (или ис- пользуемая память) алгоритма при росте размера входных данных. Обычно эту за- висимость выражают с помощью но)аций Big О (О-болыпое), О (омега) и 0 (тета). Эти нотации дают оценки соответственно сверху, снизу и в точности роста функ- ции сложности при больших размерах входных данных. ♦ Big О (О-нотация) описывает верхнюю границу роста времени выполнения ал горитма. Говоря проще. Big О показывает, как быстро увеличивается время ра- боты ал1 оритма при увеличении объема данных в худшем случае. Например, ут- верждение «алгоритм имеет сложность O(f(n))» означает, что при росте числа входных элементов п время работы не превысит некоторую константу, умно- женную на f(n) (для достаточно больших n). Big О обычно используют для оценки наихудшего сценария, который интересует разработчиков при анализе, поскольку позволяет гарантировать предел времени работы даже в самых слож- ных случаях. ♦ Big Omega (£2-нотапия), напротив, даст нижнюю границу сложности. £l(g(n)) характеризует алгоритм в лучшем случае, показывая, что алгоритм не сможет выполняться быстрее, чем функция g(n) (с точностью до константы) Иными словами, Q-оценка показывает, какой минимальный рост времени возможен при оптимальных условиях. Например, если для пузырьковой сортировки входной массив уже отсортирован, время работы будет линейным О(п)— это и есть лучший случай, соответствующий нижней границе Q(n). ♦ Big Thera (0-нотация) задает одновременно верхнюю и нижнюю границы, то есть точный порядок роста времени алгоритма. Когда говорят, что алгоритм имеет сложность 0(h(n)), это означает, что найдены такие верхняя и нижняя оценки, растущие как h(n), между которыми лежит функция времени алгоритма
Пи факту ©-нотацию часто применяют для характеристики среднего случая ра- боты алгоритма. Если для всех входных данных порядка п время всегда пропор- пиона 1ьн о. — например, nlogn, то можно записать, что атгоритм имеет слож- ность 0(n log п). Нужно понимать все эти обозначения на примерах. Рассмотрим несколько катего- рий сложности алгоритмов: ♦ 0(1), константная сложность: время выполнения не зависит от размера входа. Например, доступ к элементу массива по индексу выполняется за постоянное время. Даже если массив в тысячу раз больше, операция чтения элемента А[к] всё равно выполнится за одно и то же время, т. к. это простое обращение по ад- ресу в памяти. ♦ O(log п), логарифмическая сложность: время растет логарифмически. При- мер — бинарный поиск в отсортированном массиве. Если размер массива уве- личивается вдвое, число шагов бинарного поиска увеличится незначительно (на 1 шаг, т. к. log?(2n) = log? n +• 1). Логарифмические алгоритмы очень эффективны на больших данных, так как рост времени замедляется по мере увеличения п ♦ О(п), линейная сложность: время выполнения пропорционально количеству элементов Например, простой проход по списку или массиву для подсчета сум- мы элементов — это О(п). Если данных вдвое больше, работа займет примерно вдвое больше шагов. ♦ O(n log п), линейно-логарифмическая: растет быстрее линейной, но медленнее квадратичной. Пример — классические эффективные алгоритмы сортировки (слиянием, быстрая сортировка в среднем) имеют сложность O(n logn). Для 1000 элементов такие алгоритмы выполняют порядка 10001og2( 1 000) 1000-10 = 10000 условных операций. ♦ О(п2), квадратичная сложность: время пропорционально квадрату числа эле- ментов — часто возникает при двойных вложенных циклах. Пример - наивная сортировка пузырьком, которая в худшем случае сравнивает каждую пару эле- ментов. имеет сложность О(п2). Увеличение объема данных значительно увели- чивает время — например, чтобы отсортировать 1000 элементов пузырьком, по- требуется ~1 000 000 операций, а для 10 000 элементов — уже —100 миллионов операций. ♦ О(п3) и О(п!) — еще более крутые росты сложности. О(п3) может возникать в алгоритмах с тремя вложенными циклами (например, перебор троек элементов). О(п!)— факториальная сложность, встречается при переборе всех перестановок или комбинаций (как в задаче коммивояжера полным перебором). Такие алго- ритмы крайне неэффективны на сколь-либо больших п: рост факториальный, и уже при п = 20 количество операций ~2.4 х Юл18. что практически невыполнимо. Приведенные обозначения помогают оценивать эффективность алгоритмов в пре- дельном случае, абстрагируясь от констант и деталей реализации. На практике ча- ще всего употребляется именно Big О как оценка сверку. Однако для полноценного анализа важно понимать и нижние границы — например, невозможно сравнить ал-
горитмы только по худшему случаю, если у одного алгоритма худший случай слу- чается редко, а у другого — всегда. Тут на помощь приходит 0-нотация, описы- вающая типичную сложность алгоритма. Стоит отметить, что сложность алгоритма может отличаться в худшем, лучшем и среднем случаях Классический пример — сортировка пузырьком: если входной массив уже упорядочен, пузырьковая сортировка пройдет по нему один раз и за- вершится за О(п) (лучший случай). Если же массив изначально упорядочен в об- ратном порядке, каждому элементу придется всплывать в конец, и время работы составит О(п2) (худший случай). В среднем же случае (например, элементы впере- мешку) сложность будет около О(п2)/2 условно, что всё равно является квадратич- ной по порядку роста. Асимптотические обозначения Big О. 0 и 0 позволяют фор- мально записать эти случаи: для пузырьковой сортировки сложность в худшем слу- чае О(п2), в лучшем — Q(n), а средняя — 0(п2). Зачем это всё знать? Знание сложности алгоритмов — необходимый навык, позво- ляющий делать обоснованный выбор решений и прогнозировать их поведение на больших данных Неоптимальный алгоритм может работать приемлемо на малых объемах, но стать узким местом, когда данные вырастут. Представьте, что нужно реализовать поиск товара на сайте интернет-магазина. Небольшой каталог можно перебрать линейно за О(п) на каждый запрос, но если товаров десятки тысяч, такой поиск будет тормозить. Зная о сложности, можно выбрать более эффективную структуру, — например, отсортировать товары и применить бинарный поиск O(log п) или организовать их в хеш-таблицу для прямого доступа 0(1). В результате пользователи получат мгновенную выдачу результатов, а бизнес — больше удовле- творенных клиентов. Анализируя алгоритмы и их сложности, мы можем заранее оценить, масштабирует- ся ли решение. Далее мы рассмотрим базовые структуры данных, поскольку выбор структуры данных тесно связан с эффективностью алгоритмов: правильная струк- тура позволяет достичь оптимальной сложности для требуемых операций (поиска, вставки, удаления и т. д.). Структуры данных: массивы, списки, хеш-таблицы, деревья Структуры данных — это способы организации данных в памяти для эффективного доступа и управления ими. Различные структуры оптимизированы под разные опе- рации. Рассмотрим основные из них: массивы, связные списки, хеш-таблипы и деревья. Опишем их свойства, области применения, преимущества и недостатки. Массивы Массив — это простейшая структура данных, представляющая собой непрерывный блок памяти, разбитый на ячейки одинакового размера. Все элементы массива хра- нятся подряд друг за другом, и к каждому можно обратиться по индексу (номеру
позиции). Например, если массив А содержит п элементов, они хранятся по адре- сам подряд, а элемент А[>] находится в i-й позиции от начала. Благодаря этому свойству доступ к элементу по индексу осуществляется за константное время 0(1) — достаточно вычислить адрес и обратиться к памяти Эта особенность делает массивы очень быстрыми для чтения'записи произвольного элемента. ♦ Преимущества массива. Простая структура, превосходная локальность данных в памяти (элементы ря- дом — эффективно используются кеши процессора), мгновенный доступ по ин- дексу. Массивы хорошо подходят для случаев, когда известно фиксированное количество элементов или нужно часто обращаться к элементам по индексам Например, для хранения координат точки [х, у, zj удобно использовать массив из трех элементов ♦ Недостаток массива. Фиксированный размер (в классическом статическом массиве). Если нужно до- бавить или удалить элемент, размер массива изменить нельзя — приходится создавать новый массив большего размера и копировать данные. Удаление эле- мента тоже проблематично— нельзя «сдвинуты» память, не переписав остав- шуюся часть массива поверх удаленною элемента. Кроме того, операции встав- ки и удаления внутри массива (в начале или середине) требуют сдвига значи- тельной части элементов, что занимает О(п) времени. Например, при вставке нового элемента на позицию 0 нужно скопировать все существующие элементы вправо на одну позицию Чтобы преодолеть ограниченность размеров, во многих языках используются ди- намические массивы — структуры, которые могут изменять свой размер Идея динамического массива в том, что внутри он содержит обычный массив (буфер) некоторой емкости. При исчерпании свободного места выделяется новый массив большего размера, и все элементы копируются туда. Например, динамический мас- сив может удваивать свою емкость при заполнении. Это копирование — дорогая операция 0(п), но благодаря редкому удвоению амортизированная сложность вставки элемента в конец всё равно остается близкой к 0(1) в среднем. Многие языки имеют динамические массивы в стандартной библиотеке: в C++ это std:;vector, в Java ArrayList, в C# — List. В Python списки (list) тоже реализованы как дина- мические массивы. В нашем же случае, в Go, динамический массив представлен структурой, называемой срезом (slice). — о нем подробно поговорим далее. Еще одна особенность массивов— поскольку элементы хранятся подряд, массив заранее резервирует непрерывный блок памяти. Если массив очень большой, может быть сложно найти единый участок нужного размера, что иногда ведет к фрагмен- тации памяти. С другой стороны, последовательное размещение обеспечивает ми- нимальные накладные расходы: только хранившиеся данные, без дополнительных указателей или структур. Когда использовать массив? Когда нужно быстро получать элементы ио индексу и размер набора данных относительно стабилен или заранее известен. Например.
для доступа к элементам по вычисляемому индексу (таблица случайного доступа) массивы идеальны. Однако, если требуется часто вставлять или удалять элементы в середине структуры, массивы неэффективны, и лучше выбрать другой тип — на- пример. связный список. Связные списки Связный список это динамическая структура данных, состоящая из узлов, каж- дый из которых хранит значение и ссылку (указатель) на следующий узел. В ооно- связном списке каждый узел указывает только на следующий, а в двусвязном — еще и на предыдущий. В памяти элементы списка не обязаны находиться подряд: каж- дый элемент знает расположение следующего, образуя цепочку. Простейший пример — односвязный список У него есть головной узел, от кото- рого последовательность ссылается на следующий и так далее, до последнего узла, чей указатель равен nil. Например, список с элементами [5, 12, 7] может храниться в памяти как несколько разбросанных объектов: узел] (значение 5, ссылка на узел2), узел2 (12, ссылка на узелЗ), узелЗ (7, ссылка nil). Чтобы пройтись по списку, мы начинаем с головы и следуем по ссылкам. ♦ Преимущества связных списков. Они легко расширяются — чтобы добавить элемент, достаточно изменить пару ссылок. Вставка или удаление элемента, зная позицию (например, имея указа- тель на узел или его предыдущий узел), выполняется за 0(1), просто перена- правлением указателей. В отличие от массива, не нужно двигать остальные эле- менты в памяти — достаточно перестроить локальные связи. Связные списки эффективно справляются с частыми вставками и удалениями в середине струк- туры. Они также не требуют выделения одного большого блока памяти — новые узлы могут выделяться по мере необходимости распределенно. ♦ Недостатки связных списков. Главное — .медленный произвольный доступ. Чтобы получить элемент с индек- сом к, придется начать от начала списка и перейти по цепочке к раз В худшем случае (доступ к последнему элементу) это О(п). Таким образом, операции по- иска по значению или обращения по индексу в списке значительно менее эф- фективны, чем в массиве. Другой минус — дополнительные расходы памяти на хранение ссылок. Каждый узел содержит не только данные, но и как минимум один указатель (или два в двусвязном списке). Это накладные расходы, плюс сами узлы могут лежать в разных местах памяти, ухудшая локальность и произ- водительность кеша. В итоге на практических данных иногда оказывается, что даже при вставках/удалениях динамический массив (реализуемый на базе мас- сива с перемещением элементов) работает не медленнее связного списка, благо- даря лучшему использованию процессорного кеша. Также связные списки затрудняют использование современных оптимизаций. Например, векторные операции легко применять к массиву, где данные под-
ряд, но сложно — к связанному списку, где данные разбросаны Поэтому для высокопроизводительных вычислений предпочтительнее массивы или другие компактные структуры Когда использовать связный список? Когда количество данных сильно меняется и часто требуется вставка/удаление в произвольных местах, особенно в начале структуры. Классический случай — реализация очереди с непредсказуемым числом элементов связный список позволяет за 0(1) добавлять элементы в конец и удалять из начала, не двигая остальные. Однако, если нужен быстрый доступ по индексу или частые поиски элементов, связный список не подойдет. Стоит отметить, что в высокоуровневых языках связные списки используются реже, так как динамиче- ские массивы зачастую дают приемлемую производительность с более простым кодом. Хеш-таблицы Хеш-таблица— структура данных, хранящая пары «ключ-значение» и дающая очень быстрый поиск по ключу. Хеш-таблицу иногда называют ассоциативным массивом или словарем — в ней можно по произвольному ключу (например, стро- ке) найти связанную с ним информацию. Примеры: телефонная книга (ключ: имя, значение: номер телефона) или база данных по идентификатору. В основе хеш-таблицы лежит хеш-функция — способ преобразования ключа в ин- декс (адрес) массива. Внутри хеш-таблица обычно содержит массив (называемый бакетом или таблицей). Для операции вставки или поиска вычисляется хеш клю- ча — индекс, куда этот элемент должен поместиться. Если по этому индексу еще ничего нет, элемент записывается. Если уже есть элемент с тем же индексом (кол- лизия), применяется метод разрешения коллизий, — например, открытая адресация (поиск следующей свободной ячейки) или цепочки (связывание коллизий в список). При хорошем хешировании элементы распределяются равномерно, и средняя сложность операций (поиск, вставка, удаление) получается 0(1) — постоянное время, не зависящее от числа элементов. Именно поэтому хеш-таблицы чрезвычай- но эффективны для поиска по ключу — независимо от количества данных, время поиска почти не растет. ♦ Преимущества хеш-таблиц. Очень высокая скорость доступа по ключу на практике (за счет 0(1) операций), гибкость — ключом может быть любой объект, для которого определена хеш- функция (строка, число, пользовательский тип). Хеш-таблицы являются основой для многих высокоуровневых конструкций: словари в Python, std: :uncrdered_map в C++, HashMap в Java— всё это хеш-таблицы. Они отлично подходят для реали- заций кешей, словарей, множества уникальных элементов (через хеш-сеты) и многих других случаев, где нужно быстро проверять принадлежность или полу- чать значение по ключу.
♦ Недостатки хеш-таблиц. Отсутствие упорядоченности. Элементы хранятся не в отсортированном порядке ключей, а по позициям, определенным хеш-функцией. Если требуется и быст- рый поиск, и возможность итерироваться по элементам в сортированном поряд- ке ключей, хеш-таблица сама по себе не поможет— нужно либо отдельно сор- тировать ключи, либо использовать другую структуру (например, дерево). Так- же возможны коллизии— случаи, когда разным ключам соответствует один индекс Хорошие хеш-функпии и механизмы разрешения коллизий минимизи- руют потери производительности, но в худшем теоретическом случае, если все ключи дадут один хеш, операция поиска деградирует до О(п). Впрочем, в реаль- ности при качественной функции и разумном размере таблицы этого не проис- ходит Хеш-таблипы обычно занимают немного больше памяти, чем объем дан- ных,— для поддержания быстрого доступа их размер (число бакетов) поддер- живают с некоторым запасом относительно числа элементов (коэффициент заполнения < 1), чтобы снизить число коллизий. При росте элементов хеш- таблица периодически рехешируется — создается новый больший массив, и все существующие пары перераспределяются по новым индексам. Когда использовать хеш-таблицу? Когда нужен быстрый доступ к данным по произвольным ключам, а упорядоченность не важна. Примеры: кеширование ре- зультатов (ключ— запрос, значение— ответ), подсчет частоты слов в тексте (ключ — слово, значение — счетчик), хранение настроек по именам параметров и т. д. Если же важен порядок (например, нужно получать минимальный ключ быст- ро или итерироваться в порядке возрастания ключей), то лучше обратить внимание на структуры дерева (например, сбалансированные бинарные деревья поиска), ко- торые обеспечивают сортировку по ключам. Деревья Дерево — это иерархическая структура данных, состоящая из узлов, связанных от- ношением «родитель-потомок». Дерево напоминает перевернутое настоящее дерево; есть корень, от него отходят ветви — дочерние узлы, от них в свою очередь могут отходить свои потомки и т. д. Если проводить аналогию с организацией информа- ции, дерево это структура для представления данных с отношениями вложенно- сти (например, файловая система с папками и файлами). Однако в контексте алго- ритмов нам особенно интересны деревья поиска — структуры, которые хранят элементы (ключи) таким образом, чтобы поддерживать быстрый поиск, вставку и удаление по значению ключа. Наиболее распространенный вид— бинарное дерево поиска (Binary Search Tree, BST). В бинарном дереве каждый узел имеет не более двух потомков: левою и пра- вого. Дерево поиска накладывает упорядоченность: для любого узла с ключом X все ключи в левом поддереве меньше X, а в правом поддереве — больше X. Благо- даря этому свойству поиск элемента можно осуществлять примерно как бинарный поиск в массиве: сравниваем искомый ключ с корнем, если равен — нашли, если меньше — идем в левое поддерево, если больше — в правое. Рекурсивно повторяем
сравнение, спускаясь по дереву. В среднем число сравнений при поиске — порядка высоты дерева. Высота бинарного дерева — это длина максимальной цепочки от корня до листа (узла без детей). В лучшем случае (дерево сбалансировано) высота ~O(iog п) при п узлах, и тогда поиск, вставка, удаление занимают O(log п) време- ни — очень эффективно для больших данных. Однако в худшем случае (например, если элементы добавлялись по возрастанию, постоянно отходя вправо) дерево вы- рождается в цепочку высоты ~п, и поиск деградирует до О(п). Таким образом, про- стое бинарное дерево поиска без дополнительных мер балансировки не гаранти- рует хорошей производительности в худшем случае. Тем не менее BST ценны тем, что хранят элементы в сортированном порядке, — обход такого дерева (инфикс- ный, т. е. слева направо) выдаст все ключи по возрастанию. Применения В ST: множество и словарь с сортированными ключами. Во многих языках стандартные классы Treeset и тгеемар реализованы на основе сбалансиро- ванных бинарных деревьев BST также используются в алгоритмах, требующих динамически поддерживать упорядоченное множество, — например, в задачах вы- числительной геометрии (отслеживание пересечений отрезков с помощью «скани- рующей прямой») или при реализации приоритетных структур (например, сбалан- сированное дерево отрезков и др.). Чтобы избежать вырождения дерева, применяют раз;1ичные техники балансировки. Сбалансированное дерево автоматически поддерживает приблизительно равную высоту левого и правого поддерева для каждого узла. Благодаря этому высота все- гда O(log п), и операции выполняются за логарифмическое время в худшем случае. Примером является AVL-дерево — первый исторически известный тип самобалан- сирующегося бинарного дерева поиска. В AVL-деревс для каждого узла выполня- ется условие: высоты его левого и правого поддерева отличаются не более чем на 1. При каждой вставке или удалении узла AVL-дерево проверяет баланс высот, и если он нарушен (разница стала 2), выполняет вращения — локальные перестройки де- рева. восстанавливающие баланс. В результате (как доказывают создатели AVL- деревьсв Адельсон-Вельский и Ландис) высота AVL-дерева всегда ~1.44-log2(n) или меньше, то есть операции поиска, вставки и удаления работают за O(log п) в худшем случае. Плата за это— дополнительные вращения при модификациях: вставка или удаление могул затратить несколько вращений (их число тоже ограни- чено константой, нс более O(log п) в сумме на операцию). AVL-дерево стремится к максимальной сбалансированности, благодаря чему поиск выполняется чуть быст- рее по сравнению с менее строго балансируемыми деревьями (например, красно- черными), зато вставки и удаления могут оказаться несколько тяжелее из-за боль- шего числа балансировочных операций. Другой широко распространенный класс самобалансирующихся BST — красно- черные деревья Они поддерживают батане более гибко (вводя дополнительный бит цвета узла с правилами, гарантирующими сбагансированносгь). Красно-черные деревья чуть менее строго выровнены, чем AVL, но зато вставки и удаления реали- зуются проще. Многие стандартные библиотеки (тгеемар в Java, std: :map в C++) ис-
пользуют красно-черные деревья благодаря их хорошему компромиссу между ско- ростью поиска и модификации. В целом, и AVL, и красно-черные, и другие вариан- ты (Тгеар, Splay-дерево и др.) обеспечивают сложность операций Ot'log п) на всех данных, что делает их гораздо более предсказуемыми и надежными в производи- тельности, чем обычные несбалансированные BST. Теперь перейдем к В-деревьям — еще одному типу деревьев поиска, предназна- ченному в первую очередь для работы с большими объемами данных на внешней памяти (диски, SSD). В-дерево — это многопутевое (много ветвей) дерево поис- ка, в котором каждый узел может содержать несколько ключей и иметь больше двух потомков Проще говоря, В-дерево обобщает идею BST: вместо ограничения «не более 2 потомков» разрешается до m потомков (где ш — порядок В-дерева). Например, узел В-дерева порядка 4 может иметь до 4 детей и содержать до 3 ключей внугри себя. Ключи внутри узла разделяют диапазоны значений для по- томков (похожим образом, как бинарное дерево делит на две части, В-дерево де- лит на m частей). Особенности В-дерева: оно всегда сбалансировано по высоте — все листовые уз- лы находятся на одном уровне, Поиск в В-дереве работает аналогично BST, но на каждом узле нужно делать не двоичный выбор, а многовариантный, — определять, в какого из детей спускаться, сравнивая ключ с несколькими ключами узла. Типич- ная высота В-дерева при больших, го намного меньше, чем у бинарного дерева с тем же числом элементов, потому что ветвление шире. Это критично для хранения дан- ных на дисках — чтение большого угла (например, 4 Кбайт, содержащего десятки или сотни ключей) с диска почти так же быстро, как чтение одного числа, поэтому лучше прочитать сразу крупный блок и сократить количество дисковых операций (глубину дерева). Именно поэтому В-деревья широко используются в базах данных и файловых системах для организации индексов. Например, индексы в реляцион- ных СУБД (MySQL, PostgreSQL) обычно реализованы как В+-деревья — вариант В-дерева. в котором все данные хранятся в листьях, а внутренние узлы содержат только ключи для навигации. В-деревья обеспечивают поиск и операции за O(logm(n)), где m— число дочерних указателей в узле. Поскольку m— величина обычно порядка десятков и более, logm(n) значительно меньше, чем log2(n). Таким образом, даже очень большие базы (миллионы и миллиарды записей) могут быть организованы в деревья с небольшой высотой (например, высота 3 или 4), что по- зволяет быстро находить данные и минимальным количеством обращений к памяти. Плюсы В-дерева: эффективная работа с внешней памятью, высокая степень ветв- ления и, как следствие, малая высота дерева. Минусы: более сложная реализация вставки и удаления (при переполнении узлов нужно их разделять, при недостат- ке — сливать или перераспределять ключи между соседними узлами). Впрочем, эти алгоритмы хорошо изучены, и есть готовые реализации. Дтя работы чисто в опера- тивной памяти на относительно небольших объемах В-дерево обычно не исполь- зуют— проще взять бинарное сбалансированное дерево Но когда речь идет о больших структурах на диске или в кеше — В-дсрсво становится структурой выбора.
Подведем итог выбору структур данных: ♦ Массивы подходят для небольших и фиксированных наборов, быстрого чтения по индексу. ♦ Связные списки — для сценариев с частыми вставками/удалениями в произ- вольных местах, но без произвольного доступа. ♦ Хеш-таблицы — для сверхбыстрого доступа по ключу, когда не нужен порядок. ♦ Деревья — для хранения в отсоргированном виде с приемлемой скоростью по- иска и операций. В следующем разделе мы рассмотрим, как эти структуры реализуются и использу- ются в языке Go. а также сравним их с Python и Java. Структуры данных в Go Я тык Go предлагает базовые встроенные структуры: массивы, срезы (slices), хеш- таблицы (шар), а также стандартные контейнеры вроде связного списка В то же время в стандартной библиотеке Go отсутствуют некоторые структуры, привычные в других языках (например, нет встроенного сбалансированного дерева или очере- ди с приоритетом— их можно реализовать самостоятельно или использовать внешние библиотеки). Рассмотрим, как указанные структуры данных представлены или реализуются в Go, приведем примеры кода и обсудим идиоматичные подходы, а также сравним их с Python и Java. Массивы и срезы в Go В Go есть понятие массива фиксированной длины (type [N] т — N элементов типа Т), однако гораздо чаше используются срезы — динамические последовательности. Срез (slice) в Go можно представить как обертку над массивом, которая хранит три вещи, указатель на внутренний массив, текущую длину и емкость (capacity). На- пример, срез типа [ ] int содержит указатель на array (базовый массив типа *int), len и cap. Длина — число элементов среза, а емкость — размер базово! о массива (мо- жет быть больше длины, чтобы оставить запас места для добавления новых элемен- тов без перевыделения). Объявить массив в Go можно так (листинг 1.1). Листинг 1.1 ................ ... ..... .......... var arr [5]int II массив из 5 целых, по умолчаник: [0,0, 0,0,0] агг[0] = 10 fmt.Println(arr;O], len(arr)) Il вывои: 10 5 Но массивы фиксированы по размеру, и литералы массивов используются редко. Чаще создают срезы. Вот несколько способов создания среза (листинг 1.2).
Листинг 1,2 si := []int{2, 4, 6} // срез из литерала (1еп=3, сар=3) s2 := make([]int, 5) // срез длины 5 (инициализирован нулями, сар=5) s3 := make([]int, 3, 5) // длина 3, емкость 5 arr := [4]string{"a","t","с","d"} s4 := arr[1:3] // срез, ссылающийся на часть массива arr // (элементы с инд. 1 по 2) В приведенных примерах si непосредственно содержит 3 элемента. s2 — длина 5, все элементы нулевые. зЗ — длина 3, но буфер рассчитан на 5 (можно добавить до 2 элементов, не изменяя буфер). s4 — срез, отделяющий кусок готового массива arr. Операция arr [1:3] создает срез длиной 2 (cap будет равна 3, гак как от позиции 1 до конца массива 4 элементов доступно 3 элемента емкости). Такой срез — лишь окно на существующие данные массива. Если изменить s4[С], изменится и агг[1], так как они разделяют память. Основной процесс срезов происходит при добавлении элементов. Функция append (slice, elems...) добавляет новые элементы в конец среза, при необходимо- сти увеличивая его емкость. Например, как показано в листинге ) .3 Листинг 1.3 r.urns := []int{l, 2, 3} fmt.Printf("len=%d cap=%d\n", len(nuirs), cap(nums)) // len=3 cap=3 nums = append (nun’s, 4) fmt.Printf("len=%d cap=%d\n", len(nums), cap(nums)) // len=4 cap=6 // (емкость выросла) nums = append(nums, 5, 6, 7) fmt.Printf("len=%d cap=%d\n", len(nums), cap(nums)) // ler.=7 cap=12 // (снова выросла) В этом коде начальный срез имел 1еп=3, сар=3. Добавление четвертого элемента вызвало выделение нового массива большей емкости (обычно стратегия — увели- чить в -1.5—2 раза), после чего cap стал 6. Добавление еше трех элементов превы- сило текущую емкость 6, поэтому Go вновь выделил больший массив (сар=12) и скопировал данные. Такие перераспределение происходит прозрачно Важный мо- мент: при перемещении на новый базовый массив старый остается позади, и срез отсоединяется от исходных данных. В этом примере, если бы у нас были другие срезы, ссылавшиеся на прежний массив, они не затронутая новым append, поскольку получили отдельную копию данных. Срезы — это идиоматичный способ работы с последовательностями в Go. Почти все функции стандартной библиотеки, работающие с коллекциями, используют срезы вместо массивов. Например, пакет sort предоставляет sort.ir.tsfs []int) для сортировки среза, и т. д. Прямое использование типа массива [N] т встречается ред- ко — разве что когда длина известна на этапе компиляции и имеет особый смысл.
Чаще массивы служат внутренней кухней для срезов или используются в низко- уровневых пакетах, где нужен фиксированный буфер. Идиоматические приемы работы со срезами: ♦ Итерирование по срезу — либо традиционным индексным циклом: for i := 0; i < len(s); i++ ♦ либо удобным range-циклом: for index, value := range s { ... }. ♦ Добавление; s = append(s, x) — возвращает новый срез (может указывать на но- вый массив, если была реаллокапия). ♦ Копирование: встроенная функция copy(dest, зге; копирует минимальную из двух длин элементов. Например, сору(а, Ь; скопирует 1еп(Ь) элементов в а (или 1еп (а) — что меньше). ♦ Вырезание (удаление элемента) — стандартный трюк: s = append(з[:i], s[i+l:]...) удалит элемент с индексом i, склеив два отрезка того же среза (до и после уда- ленного). Это возможно, поскольку append может принимать второй аргумент, распакованный как список (...). Сравнение с дру! ими языками: ♦ в Python аналогом среза является список (list). Python-список динамически расширяется похожим образом и также поддерживает срезы (операции ist[a:b]). Разница— Python-список может содержать элементы разных типов и имеет больше встроенных операций (вставка в середин)', удаление по значению и т. п.), но принцип динамического массива тот же; ♦ в Java есть два уровня; массивы (int[] arr)— фиксированного размера, и клас- сы ArrayList, Vector— динамические массивы. Go облегчает эту ситуацию, пре- доставляя одну концепцию slices для всех этих случаев. В Go срез — это по сути аналог Java ArrayList, только встроенный в язык. В отличие от Java, где нужно использовать методы класса, в Go операция добавления append— это встроенная функция, а доступ по индексу осуществляется гак же, как в массиве; ♦ как Python, так и Java скрывают от программиста детали увеличения буфера. Go делает то же самое (capacity растет автоматически). Отметим, что срезы Go по- хожи на списки Python и ArrayList Java и по функциональности, но Go-срезы статически типизированные, то есть все элементы одного определенного типа. Связные списки в Go В стандартной библиотеке (io есть пакет container/iist, реализующий двусвязный связный список. Обычно потребности в связных списках в Go возникают нечасто (из-за существования срезов), но иногда они полезны — например, для реализации LRU-кеша или очереди с частыми удалениями из середины.
Интерфейс container/iist простой: гип List представляет список, содержащий эле- менты типа any (интерфейс!}, то есть любые). Есть методы для добавления и удале- ния узлов, а также перемещения их в начало/конец за постоянное время. Пример использования приведен в листинге 1.4. import ( "container/iist" "fmt" ) func main() { 1 := iist.NewO // создаем новый пустой список 1 . PushEackCGo") // добавляем элементы в конец 1 .PushEack("Java") 1 .PushFront("Python") 11 добавляем в начало // Итерация пс списку и вывод значений for е := 1.Front(); е 1= nil; е = e.NextO { fmt.Print 1г. (е.Value; } // Вывод порядка элементов: Python, Gc, Java // Удаление первого элемента е := 1. Front () 1.Remove(е) fmt.Printin("After removal:") for e := l.FrontO; e != nil; e = e.NextO { fmt.Printin(e.Value) } // Вывод: Go, Java } В этом примере мы создали список строк. Метод pushBack вернул указатель на эле- мент (узел) типа ‘Element, который хранит Value и ссылки Next/Prev. Мы не сохрани- ли эти указатели, кроме как для удаления первого элемента. Remove удаляет задан- ный элемент Итерация достигается через получение первого элемента i. Front () и переходно .Nextо. Пакет container/1д st реализует двусвязный список эффективным способом— все основные операции выполняются за 0(1). Однако, как и в общем случае связных списков, поиск элемента по значению потребует 0(и). Поэтому, если нужен список только для последовательного обхода и индексация не важна, часто проще исполь- зовать срез. Например, обход элементов можно сделать и по срезу, а вставку в се- редину— через append двух частей, как упоминалось ранее. Тем не менее container/iist находит свое применение, когда необходимы именно семантика спи-
ска и постоянные адреса элементов (узлы не перемешаются в памяти при вставках, в отличие от среза, который может перераспределить массив). Сравнение с другими языками: ♦ Python не предоставляет явной реализации связною списка в стандартной биб- лиозеке. Есть класс deque (двусторонняя очередь) в модуле collections, который реализован эффективно, но «под капотом» он ближе к кольцевому буферу (мас- сиву), а не к списку. Разработчики Python обычно обходятся списками или ис- пользуют специализированные структуры; ♦ Java имеет класс LinkedList<E>, реализующий двусвязный список, но в реальных приложениях его применяют редко — чаше предпочитают ArrayList из-за луч- шей производительности на современных процессорах. Go склоняется к тому же— прежде чем брать container/iist, подумайте, не справится ли обычный slice. Впрочем, когда нужна именно логика списка, cor.tainer/list предоставляет готовое решение. Хеш-таблицы (отображения) в Go В> Go встроена поддержка ассоциативных массивов через тип map[KeyType]ValueType, известный просто как тар. Это полноценная хеш-таблица, управляемая рантаймом Go. Создать карту можно так (листинг 1.5). m := make(map[string]int) m["Eva"] = 5 m["Artem"] = 3 fmt,Printin(m["Eva"]) 11 пустая мапа с ключами-отроками // и значениями-числами !/ вывод: 5 Здесь мы создали map [string] mt В качестве ключей могут выступать любые срав- нимые типы: строки, числа, структуры (если все поля сравнимы), указатели и т. д. Нельзя использовать в качестве ключа срезы, функции и другие типы, которые не имеют оператора сравнения (это ограничение связано с тем, что хеш-таблица должна уметь сравнивать ключи для проверки равенства). В приведенном примере мы добавили две записи: ключ "Eva" —> значение 5 и ключ "Artem" —> 3. Обращение m["Eva"] вернет 5. Если ключа нет, возвращается значение нулевого типа (для int это 0) и в отдельной переменной можно проверить факт существования ключа (листинг 1.6). val, ok = m["Nikolay"] if lok { fmt.Prinrln("No entry for 'Nikolay'")
Здесь ок будет false, так как ключ "Nikolay" отсутствует. Внутренне тар в Go реализована через хеш-таблицу с открытой адресацией и рехе- шированием при заполнении (точные детали реализации могут меняться от версии к версии, но для программиста важно, что средняя сложность операций— 0(1)). Как и в других языках, Go-мапа не гарантирует какого-либо порядка ключей: при итерации по for k, v := range m порядок будет произвольным (и даже может ме- няться между запусками программы для безопасности и надежности). Поэтому тар с.1ужит для быстрого неупорядоченного доступа по ключу. Основные операции с тар: ♦ Чтение: m[key] (синтаксис как для массива?среза, но работает по ключу произ- вольного типа). ♦ Задись/добавление: m[key] = value (если ключ отсутствовал— добавится, если был — значение перезапишется). ♦ Удаление: встроенная функция delete(m, key) удаляет пару по ключу (ничего не делает, если ключа нет). ♦ Итерирование: for k, v := range m { ... } обойдет все пары в неопределенном порядке. Пример (листинг 1.7). “-sr™ " *-- ™ = ’-’ 1 Листинг 1.7 scores := map[string]int{ "Eva": 5, "Artem": 3, } scores["Nikolay"] = 4 fmt.Printin("Artem1s score:", scores["Arcem"]) // 3 delete(scores, "Eva") for name, score := range scores { fmt.Printf("%s -> %d\n", name, score) } I, Епвод (порядок произвольный): // Nikolay -> 4 // Artem -> 3 Как можно видеть, синтаксис очень лаконичный по сравнению с, скажем, Java (где пришлось бы работать с объектом назьмар, вызывать методы put/get). Go автомати- чески управляет памятью и ростом хеш-таблицы. Изначально создается небольшой словарь, при заполнении он будет увеличиваться (происходит рехеширование, про- зрачное для программиста).
Идиоматичные моменты. ♦ Проверка наличия ключа — чаще всего делается через двузначное присваива- ние: val, ok := m[key]. Если ok == false, значит, ключа нет (либо значение равно нулевому, чзо нужно отличать). Например, чтобы инкрементировать счетчик по ключу: if ok := counters[k]; !ok ( counters[k] = 0 ); counters[k]++ ♦ Nil map— переменная типа map[T]u по умолчанию имеет значение nil (нулевой указатель на тар). Обращение in [key] на nil-map вызовет runtime panic, поэтому каргу надо либо сразу инициализировать литералом, либо через make. После make она готова к использованию. Интересно, что delete на nil-map безопасен (просто ничего не делает), а вот присваивание будет ошибкой. ♦ Сравнение тар — нельзя напрямую сравнить два шар на равенство (за исключени- ем сравнения с nil). Поэтому, чтобы проверить равенство содержимого, нужно вручную сравнивать ключи/значсния. Сравнение с другими языками: ♦ в Python аналог— встроенный тип diet, который также реализован как хеш- таблица. В Python ключом может быть любой хешируемый объект (имеющий хеш-функцию и поддерживающий сравнение на равенство). Синтаксис похож: d = (“Eva": 5}, доступ d["Eva"], удаление del d [ "Eva" ]. Отличие—в Python словари с Python 3.7 сохраняют порядок вставки (то есть стали упорядоченными по по- рядку добавления, хотя сложность операций осталась амортизационно 0(1)) В Go на порядок никак полагаться нельзя: ♦ в Jara соответствие— интерфейс Map<K,v>, и две основные реализации: HashMap (хеш-таблица, неупорядоченная) и тгееМар (бинарное дерево, упорядоченное по ключу). У Go встроенная тар ближе по духу к EashMap — она несортированная. Если нужен отсортированный словарь, в стандартной библиотеке Go решения нет. нужно либо хранить отдельную сортированную структуру ключей, либо ис- пользовать Сторонний пакет (существуют, например, golang.org/x/exp/smaps— экспериментальная sorted шар на основе деревьев). В целом, сочетание slice^map в Go покрывает большинство случаев, тогда как в Java может понадо- биться выбирать между ArrayLrst/Linkedlist и HashMap/TreeMap. Деревья в Go В стандартной библиотеке Go нет готовой реализации бинарною дерева поиска или сбалансированного дерева для ассоциативного массива с упорядоченными ключами. Это осознанное решение: предполагается, что многие задачи можно ре- шить, используя сочетание тар (для быстрого доступа) и сортировки при необходи- мости, либо вручную реализовать нужную структуру под задачу. Тем не менее ино- гда требуется структура данных, обеспечивающая упорядоченное хранение с быст- рыми вставками/поиском В других языках для этого есть TreeMap/TreeSet (Java), std: :set/std: :map (C++), которые обычно реализованы на самоба таксирующихся
BST (красно-черных деревьях). В Go можно либо воспользоваться сторонними библиотеками, либо реализовать самостоятельно базовое дерево. Для учебных или специализированных целей несложно написать простое бинарное дерево поиска на Go. Рассмотрим небольшой пример реализации несбалансиро- ванного BST для целых чисел (чисто для иллюстрации, в реальном коде лучше ис- пользовать проверенные структуры или балансировку) (листинг 1.8). type Node struct { Key int Left *Node Right ’Node // Поиск ключа в бинарном дереве (итеративно) fur.c Search (root ’Node, key int) *Node { cur := root for cur != nil { if key == cur.Key { return cur // нашли ключ } else if key < cur.Key { cur = cur.Left // идем в левое поддерево } else { cur = cur.Right // идем в правое поддереве } } return nil //не найденс } // Вставка ключа в дерево (рекурсивно) func Insert(root ’Node, key int) ’Node { if root == nil { return &Node{Key: key) // создаем новый узел } if key < root.Key { root.Left = Insert (root.Left, key) } else if key > root.Key { root.Right = Insert(root.Right, Key' } return root } Реализация определяет структуру Node и две функции' search и insert. Функция по- иска проходит по дереву аналогично описанному ранее алгоритму: сравнивает ключ с текущим узлом и спускается влево или вправо. Вставка — рекурсивная; на-
ходим место, где должен находиться новый ключ (когда доходим до nil), и там соз- даем новый узел. Обратите внимание, что мы не обрабатываем случай равного ключа (го есть при попытке вставить дубликат наш код сейчас просто проигнори- рует вставку). Использование этого дерева может выглядеть так (листинг 1.9). Листинг 1.9 var root *Node values := []int{8, 3, IC, 1, 6, 14, 4, 7, 13} for _, v := range values { root = Insert (root, v) } // Теперь дерево root содержит эги ключи. if node := Search(root, 7); node != nil [ fmt.Printin("Found 7") Дерево, построенное из values, будет иметь следующую структуру (примерно): 8 / \ 3 10 / \ \ 1 6 14 / \ / 4 7 13 Поиск, скажем, 7 пройдет путь 8—>3—>6—>7и найдет узел. Конечно, эта структура не сбалансирована. Если вставить отсортированный массив, она выродится в цепочку. Для балансировки можно реализовать более сложные ал- горитмы (AVL, красно-черные деревья). Это выходит за рамки нашей главы, но важно понимать: Go предоставляет низкоуровневые средства, и ничто не мешает создать свой тип данных. Более того, начиная с Go 1 18, появились дженерики — параметрические типы. Мы могли бы обобщить наш BST. чтобы он работал с лю- бым типом, сравнимым по порядку (например, используя constraints.Ordered для Key). Пока что многие сторонние библиотеки уже реализовали распространенные структуры— например, есть пакет girhub.com/emirpasic/gods/trees/binarjheap (реализует кучу), .../trees/redblacktree (красно-черное дерево) и другие в составе коллекций GoDS. Также в репозитории golang.org/x/exp (экспериментальные паке- ты) предлагались структуры вроде В-дерева. Если же задача — просто хранить данные отсортированно и выполнять поиск, за- частую можно комбинировать существующие средства хранить данные в срезе и поддерживать его отсортированным. Например, операция вставки может делать append + sort, получая O(n log п) на вставку, что для умеренных п может быть при-
емлемо. Или использовать бинарный поиск (sort.Searcn) по отсортированному сре- зу, чтобы находить индексы элементов быстро (O(log п)), хотя вставка всё равно потребует сдвига элементов. * * ♦ Итак, мы заложили фундамент, а далее рассматриваем алгоритмы и структуры, зная основополагающие принципы их работы и реализации на Go Когда вы будете чи- тать и писать код на Go, всегда держите в уме вопросы: какова сложность этого решения 7 нет ли более подходящей структуры данных для моей задачи? Ответы на них напрямую влияют на качество и эффективность программного обеспечения.
- Глава 2- Поиск, сортировка и сжатие данных Алгоритмы поиска, сортировки и сжатия данных— фундаментальная основа эф- фективного протраммирования. Понимание их принципов и реализаций крайне важно для каждого разработчика. В повседневной жизни мы зачастую незаметно пользуемся этими методами. Например, Иван, просматривая телефонный справоч- ник в поисках номера Николая, интуитивно применяет двоичный поиск — разделяя книгу' пополам и отбрасывая лишние половины. В этой главе мы рассмотрим клас- сические алгоритмы поиска и сортировки, а также методы сжатия данных и поиска подстрок. Алгоритмы поиска Алгоритмы поиска позволяют эффективно находить нужный элемент в наборе дан- ных. Предположим, у нас есть отсортированный массив — как быстро определить, есть ли в нем заданное значение9 Рассмотрим три алгоритма: двоичный поиск, ин- терполяционный поиск и поиск Фибоначчи. Все они относятся к классу Divide and Conquer (разделяй и властвуй) и в среднем работают за логарифмическое время О( log п). Однако они имеют и различия в подходе. Двоичный поиск Двоичный поиск — один из простейших и в то же время самых эффективных ал- горитмов поиска в отсортированном массиве. Его идея заключается в делении об- ласти поиска пополам на каждом шаге. Алгоритм сравнивает искомый элемент х с элементом в середине массива: 1. Если середина равна х, то поиск завершен (элемент найден). 2. Если х меньше среднего элемента, значит, х может находиться только в левой половине массива (поскольку массив отсортирован). Правую половину можно отбросить. 3. Если же х больше среднего, то отбросить левую половину и продолжить поиск в правой. После каждого шага размер области поиска сокращается вдвое. Таким образом, в худшем случае двоичный поиск выполнит порядка log2(n) сравнений для массива
длины п. Например, для массива из 1 000 000 элементов потребуется примерно 20 сравнений — впечатляющая эффективность по сравнению с линейным поиском за О(п), который мог бы потребовать миллион шагов. Приведем реализацию двоичного поиска на Go Используем итеративный подход с двумя индексами: low (нижняя граница) и high (верхняя граница). Изначально low=0, high-n-1. На каждой итерации вычисляем индекс середины mid и сравниваем эле- мент arr[mid] С ИСКОМЫМ х (ЛИСТИНГ 2.1). func binarysearch(arг [] int, х int) int { low, high := 0, len(arr)-l for low <= high { mid := low + (high-low)/2 // середина диапазона if arr[mid] == x { return mid // нacini элемент, возврат,аем индекс } if arr[mid! < x { low = mid +1 // ищем в правей половине } else { high = mid - 1 // ищем в левой половине } } return -1 // элемент не найден func main() { nums := []int{l, 3, 5, 7, 9, 11, 15, 18) idx := binarySearoh(nums, 7) fmt.Printin(idx) // Вывод: 3 (т.е. элемент 7 найден пс индексу 3) } Если элемент присутствует в массиве, алгоритм вернет его индекс Если же х от- сутствует, двоичный поиск в итоге сузит диапазон до нулевой длины (low превысит high), и функция возвратит -1. Временная сложность двоичного поиска — Oilog п), а память — 0(1). Такой отличный результат достигается ценой необходимости от- сортировать данные заранее Допустим, дан отсортированный массив имен: ["Артем", "Виктор", 'Ева", "Петр", "Саша"]. Ишем имя "Ева". Двоичный поиск сначала сравнит со средним элементом "Ева" vs "Петр". Поскольку "Ева" < "Петр" в лексикофафическом порядке, дальней- ший поиск пойдет в левом подмассиве ["Артем", "Виктор", "Ева"). Здесь середина "Виктор": "Ева” > "Виктор", значит, теперь берем правую часть от "Виктора", то есть остается только ["Ева"). Совпадение найдено. Всего три сравнения вместо пяти возможных при последовательном просмотре,
Двоичный поиск — базовый алгоритм, лежащий в основе множества других ме- тодов Важно понимать, как он работает, и уметь реализовать его самостоятельно. Интерполяционный поиск Интерполяционный поиск— улучшение двоичного поиска для случайно и рав- номерно распределенных данных. В двоичном поиске середина всегда выбирается при делении пополам, без учета значения элементов. Интерполяционный же поиск пытается оценить позицию искомого элемента относительно границ диапазона, подобно тому, как мы ищем слово в бумажном словаре, прикидывая по первой бук- ве, где оно может находиться. Допустим, есть у нас отсортированный массив цен. На текущем шаге слева arr [low] = io, справа arr [high] = lio. Ищем х = то По значениям это где-то на 60% пути от 10 до ] 10. Логично проверить индекс, который тоже примерно на 60% пути между low и high. Если там не попали — сдвигаем границы и повторяем ту же при- кидку в новом, уже меньшем коридоре. Если arr [low] и arr[high] одинаковые (все значения в коридоре равны), угадывать нечего — либо нашли ровно это значение, либо его тут нет. Интерполяционный поиск наиболее эффективен, когда значения в массиве распре- делены равномерно. В среднем случае количество сравнений — порядка O(log log п), то есть растет медленнее, чем у двоичного поиска. Например, для миллиона элементов среднее число сравнений может быть ~2-3 (при условии равномерности данных). Однако в худшем случае (например, если данные сильно неоднородны или искомый элемент оказывается на самом конце массива) интерполяционный по- иск вырождается до линейного О(п). Поэтому алгоритм применяется на практике реже, чем двоичный, — преимущественно в ситуациях, где распределение данных заранее известно и благоприятно Интерполяционный поиск похож на двоичный, но вычисление mid заменяется на формулу для pos. Нужно учесть проверку деления на ноль- разность ап [high]- arr [low] не должна быть 0 (листинг 2.2). Листинг 2.2 func interpolarionSearch(arr []int, x int) int { low, high := 0, Ten (arr)-1 for low <= high S& x >= arr[low] &$ x <= arr[high] { if low == high { if arr[low] == x { return low } break
// Формула для позиции pos := low + (int(flcat64(high-low)/ fioat64(arr[hign]-arr[low])) * (x - arr [low])) if arr[pos] == x { return pos } if arr[pos] < x { low = pos +1 // смещаем границу вверх } else { high = pos - 1 // смещаем границу вниз } } return -1 } func main() { arr ! = []int[10, 12, 13, 16, 13, 19, 20, 21, 22, 23, 24, 33, 35, 42, 47) idx := interpolationsearch(arr, 18} fmt.Println(idx) Il вывод: 4 (элемент 18 найден по индексу 4) } Цикл for дополнительно проверяет условие: х >= arr[low) is х <= arr[high]. После расчета pos и сравнения диапазон сдвигает ся аналогично двоичному поиску. Применять интерполяционный поиск имеет смысл в случаях, когда данные распре- делены равномерно и требуется максимально сократить число итераций. Историче- ски он упоминался в контексте поиска в телефонных книгах или справочниках по номеру (где записи равномерно распределены по диапазону чисел). Однако его худший случай (линейный) заставляет быть осторожным. На практике чаше выби- рают двоичный поиск из-за его предсказуемости. Тем не менее интерполяционный подход демонстрирует интересный прием: использование информации о значениях данных для ускорения поиска. Поиск Фибоначчи Поиск Фибоначчи — еше один легендарный логарифмический алгоритм для от- сортированных массивов, в котором деление диапазона происходит по соотноше- ниям чисел Фибоначчи. В поиске Фибоначчи используется следующее свойство: если массив разбить не пополам, а в отношении, близком к 2/32/32/3 и 1/31/31/3 (отношения соседних чи- сел Фибоначчи ~1,618, т. е. это «золотое сечение»), го можно добиться схожего ло- гарифмического времени. Алгоритм на каждом шаге отступает от high на величину из последовательности Фибоначчи, сокращая диапазон.
Алгоритм сначала находит наименьшее число Фибоначчи F_m, которое не меньше длины массива п. Затем мы задействуем три переменные для соседних чисел Фибо- наччи: fibM (F_m), fibMi (f_{rr-i}), fioM2 (F_{m-2}). На каждом шаге сравнения будем использовать fibM2 как смешение от offset (смещенная позиция). Если сравнение неудачно, числа Фибоначчи продвигаются на следующий шаг: уменьшаются на 1 или 2 позиции в ряду (листинг 2.3). func fibonacciSearch (arr []int, x int} int ( n := len(arr) // Инициализируем Fibonacci numbers fibM2 := 0 // (m-2)-e число Фибоначчи fibMi := 1 // (m-l)-e число Фибоначчи fibM := fibM2 + fibMi // m-e число Фибоначчи for fibM < n { fibM2, fibMJ = fibMi, fibM // fibM2 = F(m-2), fibMi = F(m-l) fibM = fibM2 + fibMi // fibM = F(m) } offset := -1 // смещенный индекс (исключенные элементы слева' for fibM > 1 { // Выбираем 'проверяемый индекс i := offset + fibM2 if i >= n ( i = n - 1 } if arr[i] < x { Il Сдвигаем диапазон на одну позиции Фибоначчи вниз fibM = fibMi fibMi = fibM2 fibM2 - fibM - fibMi offset = i // смещаем начальную точку диапазона ) else if arr[i] > x { // Сдвигаем диапазон на две позиции Фибоначчи вниз ribM = fibM? fibMi = fibMi - fibM2 fibM2 = fibM - fibMi } else { return i 11 arr[i] = x, налли элемен? } ) // Последняя проверка для оставшегося элемента if fibMi = 1 is offset-tl < n S.& arr[offset+lj = x { return offset + 1 } return -1
func main () { arr := []int{2, 3, 4, 10, 40, 50, 60} idx := fibonacci.Search (arr, 10) fnt. Printin (idx) // Бынсд: 3 (числе 10 найдено по индексу 3) } Здесь offset играет роль смещенной левой границы (элементы до offset уже исклю- чены). На каждой итерации выбираем индекс i = offset + fibM2. Изначально пьм примерно равен п, а пьмз — примерно от fioM. Так реализуется неравное деление массива: одна часть ~73, другая ~'.3 размера. После сравнения, в зависимости от результата, мы либо отсекаем левую часть (смещая offset вправо и уменьшая Fibonacci на одну ступень), либо правую часть (уменьшая Fibonacci на две ступе- ни). В итоге диапазон поиска сокращается по аналогии с двоичным поиском, пока не останется 1 элемент. * * ♦ Выводы по алгоритмам поиска. Все рассмотренные алгоритмы: двоичный, ин- терполяционный и Фибоначчи — требуют отсортированных данных и работают значительно быстрее простого перебора. Двоичный поиск наиболее универсален и гарантирует O(log п) в худшем случае. Интерполяционный может быть быстрее на равномерных данных (среднее O(log log п)), но в худшем случае О(п). Поиск Фибо- наччи схож с двоичным по сложности (Oflog п)) и исторически был полезен при ограничениях аппаратуры На современном оборудовании двоичный поиск часто предпочтителен из-за простоты и предсказуемости. Тем не менее понимание аль- тернатив расширяет кругозор и помогает лучше оценить различия методов поиска. Алгоритмы сортировки Сортировка — одна из самых частых задач в программировании. От эффективной сортировки зависит скорость работы поиска, объединения данных и многих других операций. Существует множество алгоритмов сортировки, различающихся по сложности, требованиям памяти и устойчивости (стабильности). Рассмотрим четы- ре классических алгоритма сортировки: ♦ Быстрая сортировка (Quicksort)— быстрая на практике, средняя сложность O(n log п), но есть риск худшего случая О(п2). ♦ Сортировка слиянием (Merge sort) — гарантирует O(n log п) даже в худшем случае, требует дополнительную память, стабильна. ♦ Пирамидальная сортировка (Пеар sort) — также гарантированно O(n log п), выполняется на месте (in-place), по не стабильна. Основана на структуре кучи. ♦ Поразрядная сортировка (Radix sort) — линейный алгоритм O(n * d) для спе- циальных случаев (например, сортировка чисел по разрядам). Не использует сравнения элементов. Мы подробно разберемся с каждым алгоритмом, приведем код на Go и обсудим механизмы работы.
Быстрая сортировка Быстрая сортировка — пожалуй, самый известный алгоритм сортировки. Разра- ботан Тони Хоаром в 1960 году и до сих пор широко используется благодаря от- личной средней производительности. Quicksort относится к алгоритмам «разделяй и властвуй»: он рекурсивно делит массив на части вокруг выбранного опорного элемента (pivot), сортируя каждую часть отдельно. Сначала выбирается опорный элемент (обычно первый, последний или средний элемент массива). Затем массив разделяется (partition): все элементы меньше опорного помещаются слева от него, а все большие — справа Опорный оказывает- ся на своей окончательной отсортированной позиции Далее алгоритм рекурсивно сортирует левый и правый подмассивы тем же способом. Благодаря разделению примерно пополам, в среднем глубина рекурсии -log и, а на каждом уровне проис- ходит О(п) работы по сравнению и перестановке элементов. В итоге средняя слож- ность Quicksort — О(п log и). Если опорный элемент каждый раз оказывается самым большим или самым ма- леньким. разбиение неравномерное (например, массив уже отсортирован, a pivot берется как последний элемент). Тогда рекурсия деградирует: глубина ~п и слож- ность О(п2). Этот сценарий маловероятен при случайных данных, но чтобы обезо- пасить алгоритм, на практике применяют улучшения: ♦ случайный выбор pivot или «медиана из грех» (median-of-three)— уменьшает шанс неудачного разделения; ♦ ограничение глубины рекурсии — при большой глубине можно переключиться на атгоритм с гарантией (например, heapsort). Такой гибрид называется introsort. В стандартной библиотеке Go. начиная с версии 1.19, используется оптимизиро- ванный вариант pdqsort (Pattern-Defeating Quicksort) — гибрид Quicksort и вставок, устойчивый к худшим случаям. Приведем классическую реализацию быстрой сортировки для слайса целых чисел. Для простоты возьмем последний элемент как pivot и реализуем функцию partition, возвращающую индекс разделения (листинг 2.4). Листинг 2.4 func quicksort(arr []int) { if len(arr) < 2 { return // база рекурсии: 0 или 1 элемент уже отсортированы } // Выбираем последний элемент в качестве оперного pivot := arr[len(arr)-1] left := 0 for j := 0; j < len(arr)-l; j++ { if arr[j] < pivot { arr[left], arr[j] = arr[j], arr[left]
left++ } } // Помещаем pivot на корректное место (между левым // и правым сегментом) air[left], arr[len(arr)-l] = arr[len(arr)-l], arr[left] // Рекурсивно сортируем левую и правую части quicksort(arr[:left;) quicksort (arr [1 eft+1: ]) } func main() { nums := []int{10, 7, 8, 9, 1, 5} quicksort(nums) fmt.Printin(nums) Il Вывод: [1 5 7 8 9 10] 1 В функции quicksort снача.та проверяем базовый случай: если длина массива < 2, сортировка не нужна. Затем выбираем pivot и запускаем цикл: for j := 0; j < len(arr)-l; j++ в котором все элементы меньше pivot перемешаем влево, используя указатель left. После этого ставим pivot на позицию left — теперь слева от pivot все элементы меньше, справа — больше либо равны pivot. Далее рекурсивно сортируем левый подмассив arr [:left] и правый arr[left+1:]. Реализация выполняет сортировку на месте, перестановка происходит внутри ис- ходного слайса без выделения дополнительной памяти. Однако такой код не явля- ется стабильным — равные элементы могут поменять порядок относительно друг друга, что допустимо для Quicksort. Среднее время работы Quicksort— O(n log п). В тестах он обычно обгоняет другие алгоритмы за счет хорошей кеш-локальности и небольших констант. Стандартная функция sort.sort в Go изначально была близка к классическому Quicksort, но за- тем эволюционировала в mtrosort'pdqsort для улучшения устойчивости к худшим случаям. Тем не менее, с точки зрения пользовате.тя, быстрая сорт ировка остается оправданным названием — для случайных данных трудно придумать что-то быстрее. Если надо отсортировать массив имен: ["Ева", "Артем", "Борис", "Даша"], Quicksort мог бы выбрать pivot="flama". После разделения получим левую часть ["Ева", "Ар- тем", "Борис"] (все < "Даша") и правую Г] (пусто, т. к. нет никого > "Даша"). Левая часть рекурсивно сортируется; pivot, скажем, "Борис", разделение дает ["Артем"] и ["Ева"], которые уже отсортированы. В итоге итоговый порядок: ["Ар- тем", "Борис", "Даша", "Ева"] (не лексикографически, потому что pivot-выбор повли- ял — Quicksort не стабильный, например, "Ева" и "Артем" поменялись местами). Для получения лексикографического порядка на буквы, естественно, нужно выби- рать корректное сравнение (здесь для примера мы только демонстрируем принцип работ ы).
Сортировка слиянием Сортировка слиянием — еше один алгоритм «разделяй и властвуй», придуман- ный Джоном фон Нейманом в 1945 году. В отличие от Quicksort, слияние Merge sort гарантирует время O(n log п) в любом случае, поскольку раскладывает массив ровно пополам независимо от данных. Если массив состоит более чем из одного элемента, следует разделить его на две равные части. Рекурсивно отсортировать каждую половину, а затем слить (merge) две отсортированные части в один отсортированный массив. Операция слияния двух отсортированных списков длины п/2 выполняется за О(п) времени (просто проходим по двум спискам, выбирая наименьший элемент с головы каждого по очереди). Графически Merge sort можно представить как двоичное дерево рекурсии, на каждом уровне которого выполняется работа О(п) по слиянию. Глубина дере- ва — О( log п), итого трудоемкость — O(n log п). Merge sort требует дополнительной памяти О(п) для слияния — поскольку она не может гарантировать слив без буфера (нужен временный массив для результата). Алгоритм стабильный — порядок равных элементов сохраняется (при правильной реализации слияния) Эта устойчивость делает сортировку слиянием полезной, ко- гда нужно сохранять исходный порядок при равных ключах. Например, сортировка объектов сначала по одному полю, потом по другому — стабильная сортировка по- зволит второй сортировке не нарушить порядок первой. Реализуем Merge sort рекурсивно, используя дополнительный буфер при слиянии (листинг 2.5). Листаж 2J func mergeSort(arr [ ]int) []int { if len(arr) <= 1 { return arr } mid := len(arr) / 2 left := mergeSort(arr[:mid]) right := mergeSort(arr[mid:]) return merge(left, right) } func merge (left, right (]int) [jint { result := make([]int, C, len(left)+len(right)) i, j := 0, 0 for i < len(left) £& 3 < len(right) { if left[i] <= right[j] { result = append(result, lefufi]) i+ + } else { result = append(result, right!j])
j++ } } // Добавляем остатки (если в сдном из списков еще есть элементы) result = append(result, left[i:]...) result = append(result, right[j:]...) return result func main() { nums := []int{38, 27, 43, 3, 9, 82, IC) sorted := mergeSort(nums) fmt.Printin(sorted) // Вывод: [3 9 10 27 38 43 82] } Здесь mergesort возвращает новый отсортированный слайс, не изменяя исходный (чтобы код был чище, мы не сортируем ка месте). Функция merge объединяет два отсортированных списка: left и right. Она последовательно сравнивает первые не- использованные элементы обоих списков, выбирая меньший и добавляя в резуль- тат Когда один список опустеет, остаток другого просто дописывается (эти эле- менты уже больше всех ранее добавленных, и их можно добавлять пачкой). В результате получается новый отсортированный слайс. Сложность по времени — O(n log п) всегда, по памяти — О(п) дополнительно (для буферов). В стандартной библиотеке Go есть также stable sort (метод sort.stable), который реализован на основе Merge son. обеспечивая стабильность за O(n log п) с дополнительной памя- тью О(п) для ссылок. Пример. Пусть нужно отсортировать список студентов по возрастанию возраста. Если два студента ровесники, хотим сохранить порядок, в котором они шли изна- чально. Сортировка слиянием подходит идеально — она не перемешает равные элементы. Мы можем реализовать сравнение по возрасту, а в процедуре merge при равенстве ieft[i] = rignt [j ] выбирать элемент из left прежде (что мы и делаем через <= в коде — это сохранит порядок следования при равных значениях). В итоге все 20-летние студенты останутся в том же порядке, что и были, только окажутся аруппированы вместе. Пирамидальная сортировка Пирамидальная сортировка опирается на структуру данных куча (heap), отчего ее второе название — сортировка кучей (Heap sort). Куча — это двоичная пирами- да, в которой на вершине находится максимальный (или минимальный) элемент. Представить ее можно как частично отсортированное дерево. Например, для птах- кучи каждый узел больше своих потомков или равен им. Благодаря этому свойству самый большой элемент массива можно быстро найти на вершине кучи.
Сначала из массива строится пирамида (heapify) — эта операция превращает не- упорядоченный массив в структуру кучи, где arr [0] — наибольший элемент. Затем мы повторяем следующую операцию: берем максимальный элемент (корень), ме- няем его местами с последним элементом массива (тем самым чвынимаем» макси- мум в конец) и уменьшаем размер кучи на 1. После обмена нарушенное свойство кучи восстанавливается (просеиванием нового корня вниз по куче). Так, шаг за ша- гом, мы извлекаем элементы в правильном порядке. В итоге массив оказывается отсортированным но возрастанию (так как мы каждый раз помещали наибольший элемент в конец). Построение кучи выполняется за О(п). Каждый последующий шаг извлечения мак- симума и восстановления кучи — O(log п), и таких шагов п - 1. Итог — O(n log п) в худшем и среднем случае. Преимущество Heap sort— гарантированная верхняя оценка (нет квадратичного провала, как у Quicksort). Он также сортирует на месте с 0(1) дополнительной памяти. Однако алгоритм не стабильный (перестановки могут менять порядок равных элементов) и на практических данных обычно уступает Quicksort по константам скорости (из-за менее последовательного доступа к памяти). Реализуем Heap sort для слайса []int. Сделаем вспомогательную функцию heapify(arr [Jint, n, i int), которая просеивает элемент arr[i] вниз по пирамиде размера п (листинг 2.6). func heapsorttarr [lint) { n := len(arr) // Построение кучи (перегруппируем массив) for i := n/2 - 1; i >= 0; i— { heapify(arr, n, i) } // Эдин за другим извлекаем максимальный элемент из кучи for j :- n - 1; j > 0; j— { // Перемещаем текущий максимум (агг[Э]) в конец arr[0], arr[j] = arr[j], arr[0] // Восстанавливаем кучу для оставшегося массива размером j heapify(arr, j, 0) } func heapify (arr []int, n, i int) { largest := i // Инициализируем наибольший как корень 1 := 2*i + 1 // левый = 2*i + 1 г := 2*i +2 // правый = 2*i + 2 // Если левый дочерний больше корня if 1 < n && arr[l] > arr[largest] { largest = 1
// Если правый дочерний больше текущего largest if г < n is arr[г] > arr[largest] { largest = г } // Если largest изменился, меняем и продолжаем heapify if largest != i { arr[i], arr[largest] = arr[largest], arr[i] heapify(arr, n, largest) ) } func main() { nums := []int{12, 11, 13, 5, 6, 7} heapSort(nums) fmt.Printin(nums) // Бывод: [5 6 7 11 12 13] ) В коде сначала мы вызываем heapify, начиная с середины массива вниз до корня, — это превращает массив в max.-кучу (все элементы подчиняются свойству кучи). За- тем в основном цикле for j := n-1; j > 0; j—мы меняем arr [0] (максимум) И arr [j ] (последний элемент неотсортированной части). Тем самым на позицию j помеща- ется правильный по порядку элемент. У меньшаем эффективный размер кучи п на 1 (j теперь граница) и вызываем neapify на корне для восстановление кучи. Повторя- ем, пока не переберем все элементы. Функция heapify проверяет, соблюдается ли пирамидальное свойство для узла i и его потомков. Если нет, меняет местами узел с большим из потомков и рекурсивно продолжается. Таким образом, самый тяжелый элемент проваливается вниз, а более крупный поднимается наверх. Heap son хорошо подходит, когда нужна гарантия производительности и ограни- ченная память. Например, в системах реального времени, где важна предсказуе- мость времени работы. Heap sort предпочтительнее Quicksort. Также куча— удоб- ная структура при сортировке потоков данных (merge к отсортированных списков) или при выборе топ-k элементов. Однако для обшей сортировки массивов в прило- жениях чаще используют более оптимизированные комбинации, поскольку cache- friendly поведение Quicksort (или продвинутых сортировок) обеспечивает выигрыш. Интересно, что Introsorl (используемый в C++ и в Go ранних версий) переключает- ся на Heap sort, лишь когда обнаруживает ухудшение Quicksort, комбинируя луч- шее от обоих подходов. Поразрядная сортировка Все рассмотренные ранее сортировки — на основе сравнений. Их фундаменталь- ная сложность, согласно теории, не может быть лучше O(n log п) для произвольных данных. Однако если известна структура ключей (например, они представимы как
строки фиксированной длины или числа с ограниченным количеством разрядов), можно сортировать за время О(п) Поразрядная сортировка (Radix sort) относится именно к такому классу алгоритмов. Радикс-сортировка сортирует числа поразрядно: начиная с самого младшего разря- да (единицы, десятки и т. д.), или, наоборот, последовательно распределяет элемен- ты по «карманам» (buckets') по значению текущего разряда. Классический вариант для десятичных чисел — LSD (Least Significant Digit first): сначала сортируем мас- сив по младшему разряду, затем результирующий порядок сортируем по следую- щему разряду (сохраняя порядок из предыдущего шага), и так далее до старшего разряда. Благодаря стабильно проведенной на каждом шаге сортировке, после по- следнею разряда весь массив оказывается отсортирован целиком. Аналогичный подход работает и для строк фиксированной длины (сортировка по символам с конца строки к началу). Допустим, нужно отсортировать числа: 170, 45, 75, 90, 802, 24, 2, 66. Максимум — 3 разряда (802). Сначала сортируем по единицам: получим порядок (сортируя 0-9): 170. 90, 802, 2, 24, 45, 66, 75 (т. е. ...0, ...0, ...2, ...2, ...4, ...5, ...6, ...5). Затем по десят- кам труппы по десяткам 0. 1, 2,...: 802, 2, 24, 45, 66, 170, 75, 90. Затем по сотням: 2, 24,45, 66, 75, 90, 170. 802 — список отсортирован. Для простоты предположим, что сортируем неогрицательные десятичные числа. Реализуем алгоритм, используя подсортировку подсчетом (counting sort) по каждо- му разряду (листинг 2.7). Листинг 2.1 func radixSort(arr []int) []int { // Находим максимальный элемент, чтобы знать количество разрядов irax := С for , v := range arr { if v > max { max = v } ) exp := 1 // exp будет 1, 10, ICO, ... для текущего разряда for max/exp > 0 { // Выполним сортировку подсчетом пс текущему разряду (ехр) output := make([]int, len(arr)) count := make([]int, 10) // Подсчитываем количество вхождений цифр текущего разряда for _, v := range arr { digit := (v / exp) % 10 count[digit]++ } // Преобразуем count[i] так, чтобы он хранил конечные // позиции цифры for i : = 1; i < 10; i++ {
coant[i] += coant[i-1] } // Заполняем выходной массив с конца (для стабильности) for j := len(arr) - 1; j >= 0; j— { digit := (arr[j] I exp) % 10 count[digit]— output[count[digit]] = arr[j] ) Il Копируем отсортированный по текущему разряду массив обратно copy(arr, output) exp *= 10 } return arr ) func main() { nums []int(170, 45, 75, 90, 302, 24, 2, 66} sorted := raaixSort(nums) fmt.Printin(sorted) 11 Вывод: [2 24 45 66 75 90 170 802] } В этой реализации: 1. Вычисляем максимальное число, чтобы знать, сколько итераций потребуется (сколько разрядов). 2. Для каждого разряда ехр = 1, 10, 100, .. выполняем стабильную сортировку подсчетом. Массив count) с.. 9] считает, сколько элементов имеют соответст- вующую цифру. Затем префиксная сумма count[i] превращает его в индексы, теперь count [d] будет индексом последнего элемента с цифрой d в выходном массиве. 3. Пробегаем массив с конца к началу и размещаем элементы в output на позиции, указанные count (каждый раз уменьшая соответствующий счетчик). Проход с конца обеспечивает стабильность: если два элемента имели одинаковую цифру, порядок из предыдущих разрядов сохраняется 4. Копируем output обратно в arr и переходим к следующему разряду. После обработки всех разрядов массив arr будет отсортирован Сложность такого подхода O(d * (п + к)), где d— число разрядов, а к — размер алфавита (здесь 10 цифр). Если d— константа (например, 32-битные числа, d = 10 примерно, если считать десятичные разряды, или d = 32 для битов), то алгоритм линейный О(п). Если же длина ключа растет с п (например, числа с количеством разрядов ~logio(n)), то формально О(п log п). Тем не менее на практике для сортировки фиксированной разрядности (mt, строки фиксированной длины) Radix sort может быть очень эф- фективной.
Поразрядная сортировка исторически примечательна, она была применена еще на перфокартах конца XIX века Германом Холлеритом для обработки переписи насе- ления США. В нынешних же системах Radix sort используется для сортировки больших объемов числовых данных, особенно когда применяются битовые версии (например, сортировка 32-битных целых за 4 прохода по байтам). Также Radix son применяется для сортировки строк по символам (например, сортировка суффикс- ных массивов). В языке Go есть пакет sort для пользовательских типов, но если нужно экстремально быстро отсортировать, скажем, миллиард 32-битных иденти- фикаторов, Radix son на основе битовых операций может выигрывать у сортировки на основе сравнений из-за линейной сложности. Минусы поразрядной сортировки. Требуется дополнительная память О(п) для буферов. Алгоритм чувствителен к распределению длины ключей (для переменной длины слов придется дополнительно ухищряться). Но когда условия подходят — это мощный инструмент. Алгоритмы сжатия данных Алгоритмы сжатия позволяют уменьши 1ь размер данных без потери информации (в случае безусловных алгоритмов), что крайне важно при хранении и передаче данных. Мы рассмотрим: ♦ Классическое кодирование Хаффмана — построение оптимального префиксного кода на основе частот символов ♦ Алгоритм LZW— словарный метод сжатия без потерь, использующий динами- чески строящийся словарь повторяющихся подстрок. ♦ Современный алгоритм Broil i — сочетает методы LZ77. Хаффман-кодирования и модель контекстов, достигая высокой степени сжатия, используется в веб- технологиях. ♦ Алгоритм Snappy — разработан Google, напротив, жертвует степенью сжатия ради очень высокой скорости компрессии/декомпрессии, широко применяется для быстрых операций с большими данными. Алгоритм Хаффмана Кодирование Хаффмана — классический жадный алгоритм оптимального пре- фиксного кодирования, опубликованный Дэвидом Хаффманом в 1952 году. Задача1 имея набор символов с заданными частотами (вероятностями), присвоить каждому символу бинарный код такой длины, чтобы общий размер закодированно- го сообщения был минимальным. При этом код должен быть префиксным (ни один код не является префиксом друтого) — для однозначности декодирования. Самый частый символ целесообразно кодировать короткой последовательностью битов, а редкий — более длинной. Алгоритм строит бинарное дерево кодов: каж- дый лист — символ, а вес (вероятность) листа — частота символа. На каждом шаге
Хаффман берет два наименее частотных символа и объединяет их в один узел с ве- сом, равным сумме весов Этот новый узел рассматривается как «составной сим- вол» и участвует дальше, пока не объединятся все. Получается двоичное дерево, где частотные символы близко к корню (получили короткие коды), а редкие — глубже (длинные коды). Если присвоить всем левым ветвям бит о, правым — 1, то путь от корня до листа дает код символа. Код Хаффмана доказанно минимален среди всех префиксных кодов (и, следова- тельно, среди всех однозначно декодируемых кодов) для данного набора частот. Ни один другой способ кодирования символов отдельно не даст меньшей средней дли- ны сообщения. Для демонстрации реализуем построение дерева Хаффмана и генерацию кодов. В коде исполыуем структуру Node для узла дерева (листинг 2.8). import ( "container/heap" "fmt" ) Il Node - узел дерева Хаффмана type Node struct { char rune // символ (если лист) freq int // частота left ’Node right *Node } Il Минимальная куча на основе []Node type MinHeap [J *Node func (h MinHeap) Len() int { return len(h) } func (h MinHeap) Lessfi, j int) bool { return h[i].freq < h[j].freq ) func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] ) func (h ’MinHeap) Push(x interfaced) { *h = appendf’h, x.(’Ncde)) } func (h ’MinHeap) Pop() interfaced { old := *h n := len(old) item := old[n-l] *h = old[:n-l] return item } 11 Строим дерево Хаффмана по частотам символов func buildHuffrranTree (freqs map! rune] int) ’Node { h := SMinHeapd heap.Init(h)
// Создаем лист для каждого символа и добавляем в кучу for char, f := range freqs { heap.Pushfh, aNode{char: char, freq: fj) ( // Объединяем самые маленькие весы, пока не останется один узел for h.Ler.() > 1 { nodel := heap.Pop(h).(’Node) node2 := heap.Pop(h).(’Node) merged := &Node( freq: nodel. freq + r.ode2.freq, left: nodel, right: node2, } heap.Push(h, merged) } // Корень дерева return heap.Pop(h).(’Node; } Il Обход дерева для получения кодсв func buildCodes(root ’Node, prefix string, codes map[rune]string) ( if root == nil { return } // Если лист - сохраняем код if root.left = nil &S root.right = nil { codes[root.char] = prefix } buildCodes(root.left, prefix+"0", codes) buildCodes(root.right, prefix+"l", codes) } func main () ( text := "go go gopher" II Подсчитываем частоты символов freqs := ma ke(map[rune]int) fcr _, ch := range text { freqs[chj ++ } roct := buiidHuffmarTree(freqs) codes := make(map[rune]string) buildCodes(root, codes) // Выводим коды символов for ch, code := range codes { fmt.Printf("%q: %s\n", ch, code) ) }
Здесь функция buildHuffmaniree строит дерево: помещает все символы в минималь- ную кучу (минимум по частоте). Затем извлекает два узла с наименьшей частотой, создает новый родительский узел с суммарной частотой и возвращает его в кучу. Повторяет, пока в куче не останется один элемент — корень объединенного дерева. Мы использовали container/heap из стандартной библиотеки Go для удобства (реа- лизовав интерфейс для М±г.неар). 'Это дает нам очередь с приоритетом для выбора минимальных частот за O(log п) на вставку.вытаскивание. Время построения дере- ва — O(n log и) для п символов/узлов, buiidCcdss — рекурсивный обход дерева. При спуске влево добавляем "О" к префик- су, вправо— "1”. Когда достигли листа (символа), записываем его накопленный код в словарь coaes. В main берем пример строки "до до gopher", строим частоты и выводим коды. На- пример, вероятно, пробел ' ' будет самым частым символом и получит короткий код, а редкие буквы — более длинные. Результат: ' 10 'е': 1100 'д': 111 'h': 1101 'o': 0 'р': 100 'г': 101 Это лишь один из возможных вариантов (в зависимости от структуры дерева коды могут отличаться, но длины оптимальны). Видно, что самый частый символ ‘o' по- лучил код о (1 бит), а, например. *h' — 1101 (4 бита). Совокупная длина закодиро- ванной строки меньше, чем в исходной ASCII (которая включает 1 байт на символ). Код Хаффмана широко применяется в сжатии файлов (часть алгоритмов JPEG, MP3, архиваторы ZIP/GZ1P используют Хаффман-кодирование на финальном этапе) Раскодировать Хаффманом просто: читая последовательность битов, идем по дере- ву от корня: 0 — влево, 1 — вправо. Как только достигли листа — выводим символ и возвращаемся к корню за следующим символом. Так как код префиксный, ни один лишний бит не остается — разметка однозначна. Алгоритм Хаффмана гарантирует оптимальное по битовой длине кодирование ка- ждого символа по отдельности. Но он не учитывает более длинные повторяющиеся последовательности. Для этого существуют словари — например, алгоритм LZW. Алгоритм LZW LZW (Lempel-Ziv-Welch) — универсальный алгоритм сжатия без потерь, изобре- тенный Авраамом Лемпелем, Лаковом Зивом и Терри Уэлчем (оригинальная версия метода — LZ78 — была создана Лемпелем и Зивом в 1978 году и доработана Уэлчем в 1984 году). LZW относится к словарным методам, вместо того, чтобы сокращать
код отдельных символов (как Хаффман), он находит повторяющиеся подстроки и заменяет их кодовыми словами — индексами в словаре. В процессе сжатия алго- ритм сам динамически сгроит словарь, не требуя его передачи вместе с данными. Идею LZW можно описать так: 1. Изначально словарь содержит все возможные символы (для байтов — 256 сим- волов). Каждому символу соответствует код (например, 'А' — 65, 'В' — 66,... ес- ли ASCII, или 0- 255). 2. Читаем входной текст посимвольно, накапливая текущую строку w. Пока w суще- ствует в словаре — продолжаем читать, увеличивая строку. 3. Как только добавление следующего символа к приводит к тому, что строка w+k не найдена в словаре — записываем код для и (который гарантированно есть в словаре) в выход. Затем добавляем новую строку w+к в словарь с новым кодом. Теперь устанавливаем текущую строку w равной просто к и продолжаем процесс. 4. Когда входной поток кончится, выходной поток содержит последовательность кодов (каждый код может представлять не один символ, а целую последователь- ность из словаря). Вот классический пример для LZW: вход "tobeornottobeortobeornot#". Изначально словарь содержит: {'Т':..., 'С':.. , 'В':..., 'Е':..., 'R':..., 'N':..., Сжатие даст последовательность кодов, которая будет гораздо короче исходной строки. Например, первые выходные коды будут T(=code20), о(=15), в(=2), Е(=5), ..., а впоследствии вместо повторяющихся "то" будут выходить новые коды. В итоге строка из 25 символов сожмется до 17 чисел (плюс стоп-код) — экономия -23%. Реализуем функцию izwCompress (input string) [] int — на выход отдаем слайс кодов (целых чисел). Для словаря строк—»код используем таблицу (тар) (листинг 2.9). func IzwCompress (input string) []int { // Инициализируем словарь односимвольными строками diet := make(map[string]int) for i := 0; i < 256; i++ { diet[string(rune(i))] = i } nextCode := 256 w := "" // текущая строка output := []int{} for c := range input { wc := w + string(c) if , exists := dicr[wc); exists { // Если w+c уже есть в словаре, продолжаем накопление w = wc } else {
// Выдаем код для w output = append(output, dictfw]) 11 Добавляем новую фразу w+c в слсварь dict[wc] = nextCode nextCodeт+ // Новая текущая строка = с w = string(с) ) } // Выдать оставшийся код if w != "" { output = append(output, dict[w]) ) return output } func main() { data := "T0BECRN07T0BE0BT0BE0RN07" compressed := IzwCompress(data) fmt.Printin("Исходные символы: ", len(data)) fmt.Println("Сжатые коды: ", compressed) fmt.Println("Кс:1ичествс кодов: ", len(compressed)) ) Инициализируем словарь diet всеми одиночными символами (0-255 коды). Пере- менная nextcode — следующий доступный код для новых входов. Проходим по входной строке. Переменная w хранит текущую найденную подстро- ку, которая есть в словаре. На каждой итерации берем еле,дующий символ с и смот- рим wc. Если такая строка уже есть в словаре, просто обновляем w = wc (увеличива- ем текущую фразу). Если wc нет в словаре, то: 1. Выходной код = код для w (текущей фразы без нового символа). 2. Добавляем новую фразу wc в словарь с новым кодом. 3. Устанавливаем w = string (с) (начинаем новую фазу с текущего символа). 4. После цикла, если w не пуст (осталась незакодированная часть), выводим ее код. В результате получаем список кодов Эти коды можно далее побитово упаковать. Для data = "Tobeornottobeortobeornot" мы можем получить результат (коды в деся- тичном формате): [84 79 66 69 79 82 78 79 84 256 258 26С 265 259 261 263] — ЭТО И есть сжатые данные (каждый код < 265, т. е. некоторые новые коды появились). В несжатом ASCII было бы 25 байтов (25 кодов по 8 битов = 200 битов), здесь 16 кодов, часть из которых можно уложить в 8-9 битов, итого ~150 битов, то есть экономия ~25%. Чем длиннее вход (с повторяющимися фразами), тем эффектив- нее LZW: размер растет нс линейно, а медленнее, приближаясь к теоретическому пределу.
Интересно, что декодер LZW может работать, не зная заранее словаря. — он вос- производит тот же процесс. Ему нужно начать с тех же одиночных символов, а да- лее. получая очередной код, он переводит его в строку (либо, если код = nextcode - 1, делает особый случай) и добавляет новую фразу в свой словарь. То есть декодер синхронно выстраивает словарь по уже полученным данным. Этот момент неоче- виден, но работает благодаря тому, что алгоритм добавляет W+K в словарь, а деко- дер в момент получения кода для w еще не знает к (он узнает его при чтении сле- дующего кода и может достроить фразу). Важно, что и кодер, и декодер следуют одним правилам построения таблицы — тогда сжатие и восстановление происходят без ошибок. Алгоритм очень прост в реализации (не требует даже вычисления частот или сложной математики) Он стал известен благодаря формату GIF — изображения GIF сжаты именно LZW. В 80-90-х годах прошлого века LZW был защищен па- тентами (Unisys, IBM), из-за чего были ограничения на использование, но сейчас срок действия патентов истек, и алгоритм свободно применяется. Однако степень сжатия LZW часто уступает более современным методам (на однородных дан- ных он близок к энтропийному пределу, но на сложных — проигрывает LZ77). Например, ZIP-архивы давно перешли на дефляцию (1^77+Хаффман), a GIF вы- теснен PNG (тоже Ьг77+Хаффман), потому что LZW выдает более крупные фай- лы. Тем не менее концептуально LZW легкий и быстрый, его идеи живут в других словарных алгоритмах. * * * Следующие два алгоритма — Brotli и Snappy — используют идеи, схожие с и Huffman, но с разным уклоном: Brotli — максимальное сжатие, Snappy— макси- мальная скорость. Алгоритм Brotli Brotli— современный алгоритм сжатия, представленный Gocgle в 2015 году как новый стандарт для веб-компрессии контента. Brotli сочетает несколько техник: ♦ LZ77 (скользящее окно) — для поиска повторяющихся последовательностей (как в ZIP/Deflate). ♦ Статический словарь часто встречающихся фраз (около 13 тысяч слов и ком- бинаций)— уникальная особенность Brotli, которая позволяет сразу заменять популярные строки (например, "https://") одним кодом. ♦ Кодирование Хаффмана — для сжатия выходных кодов и литералов ♦ Моделирование контекста — для повышения сжимаемости последовательно- стей битов (2nd-order context modeling). ♦ Другие оптимизации, такие как совместное кодирование длины матчей и лите- ралов, адаптивные алгоритмы разделения данных на блоки, move-to-front эври- стики и др.
Все эти усовершенствования позволяют Brotli достичь сжатия лучше, чем grip Deflate. примерно на 15-25%, особенно на текстовых данных (HTML, CSS, JavaScript). Так, HTML-файлы Brotli ужимает -на 20% сильнее gzip, JS на -15%, CSS на -16%. Это существе иная экономия трафика. Brotli имеет 11 уровней сжатия. На максимальном уровне (И) достигается макси- мальная степень сжатия, но работает алгоритм медленнее (Brotli -11 еще на -19% плотнее, нежели gzip -9, но и гораздо медленнее). Тем не менее декодирование (разжатие) Brotli остается быстрым и малозатратным по памяти (окно по умолча- нию 16 MiB, уместное даже в мобильных устройствах). Поэтому Brotli отлично подходит для сжатия статических веб-ресурсов: сервер один раз плотно сжимает, клиенты быстро скачивают и распаковывают. Сегодня Brotli поддерживается всеми современными браузерами (Accept-Encoding: br) и активно применяется для сжатия HTTPS-трафика (HTML, JS, CSS). Также он находит применение в сжатии данных шрифтов (WOFF2 формат), в некоторых форматах изображений и как общий компрессор в системах, где нужен максимум сжатия. В стандартную библиотеку Go Brotli пока не включен. Однако есть официальная реализация на С (libbrotli) и несколько сторонних библиотек на Go (например, githuh.com/andybalholm/brotli— порт референсной реализации на чистом Go). Можно использовать эти библиотеки для сжатия/рас паковки Пример с использованием пакета github.com'andybaibolm/brotli (листинг 2.10). import ( "bytes" "fmt" brotli "gitbub.com/andybaiholm/brotli" ) func main () { data := []byte ("Hello, Artem arid Eva! Это текст, который мы будем сжимать с помощью Brotli.") vat buf bytes.Buffer // Создаем Brctli-Writer с максимальным уровнем сжатия writer := brotli.NewWriterLevel(ibuf, brotli.Bestcompression) writer.Write(data) writer.Close() compressed := buf.Bytes() fmt.Printf("Исходный размер: %d байт\п", lenidata)) fmt.Printf("Сжатый размер: %d байт\п", len(compressed)) ) В этом примере мы создали brotli.NewWriterLevel с максимальным сжатием. Записав гуда данные и закрыв writer, получили сжатый результат в буфере. Вывод размеров
показывает выигрыш: например, строка 100 байтов может ужаться до ~60 байтов Broth. Распаковать можно аналогично через brotli. NeuReader. «Под капотом» Brotli выполняет сложную работу: находит повторения длиной до 16 Мбайт, использует большой словарь, кодирует всё это Хаффманом. Для разра- ботчика это представляется как вызов библиотеки — но понимание принципов (LZ77 + Huffman + статический словарь + контекстное моделирование) объясняет, почему Broth дает такое хорошее сжатие. Brotli — актуальный на сегодняшний день алгоритм, оптимизированный под веб, Его имя, кстати, происходит от швейцарского слова Brotli— «булочка» (в шутку над названием gzip (от английского «zip»— «молния») и проектом Zopfli от Google). Broth фактически сменил gzip как алгоритм по умолчанию для сжатия ста- тического контента. Алгоритм Snappy Если Brotli про максимальное сжатие, то Snappy — про максимальную скорость. Snappy (первоначально назван Zippy) был разработан в Google в 2011 году. Это компрессор, основанный на принципах LZ77, но без энтропийного кодирования (нет ни Хаффмана, ни арифметического кодирования). Snappy нацелен на очень быструю работу: сжимает данные со скоростью порядка 250 Мбайт/с, распаковыва- ет -500 Мбайт/с (на одном ядре Core i7 2.26 GHz). Это в разы быстрее gzip. Обрат- ная сторона — степень сжатия хуже: сжатые данные примерно на 20 -30% больше, чем у gzip. Snappy берет идеи из LZ77: он ищет в потоке дубликаты подстрок и заменяет дальнейшие повторения ссылками на предыдущее появление (offset+length). Одна- ко. в отличие от классического Deflate, он не кодирует результат битами через Хафф.ман, а выводит простую побайтовую последовательность с тегами "literal" (буквальные данные) и "сору" (двухбайтовые или трехбайговые указатели на ранее встреченную последовательность). Формат Snappy очень простой и фиксированный: каждый блок данных либо начи- нается с заголовочного байта с двумя битами типа и остальными битами длины, либо с явным префиксом длины. Например, литералы длиной <= 60 байтов коди- руются одним байтом тег + сами данные, а копирования имеют несколько режимов для разных диапазонов смещений. Благодаря такой простоте, компрессия и деком- прессия сводятся к копированию памяти без сложных вычислений Snappy не пытается максимизировать сжатие — он пропускает некоторые возмож- ности ради скорости. Например, не использует сложных эвристик для поиска длин- нейшего матча, а скорее применяет быстрые хеши для ближайших повторов Не кодирует остаточное расстояние энтропийно— все коды фиксированной длины. Тем не менее он дает приемлемое сжатие (обычно в 2 4 раза) на многих видах дан- ных и крайне малую задержку. Поэтому Snappy популярен в системах, где кри- тичны throughput и latency: внутренняя передача данных, хранилища.
Snappy широко используется внутри Google (Bigtable, MapReduce RPC), а также во множестве open-source проектов: базах данных (MariaDB, Cassandra, LevelDB, RocksDB, MongoDB), системах дотирования и аналитики (Hadoop, Spark, InfluxDB), форматах вроде Parquete. Firefox применяет Snappy для сжатия locaistcrage в брау- зере. То есть везде, где нужно быстро прокачать большие объемы данных с мини- мальной нагрузкой на CPU. Библиотека Snappy официально доступна как github.com/golang/snappy. Ее очень просто применять (листинг 2.11). Листинг 2.11 Import ( "fmt" "github.com/golang/snappy" ) func main() { data := []byte("Hello Hello Hello Hello!") // намеренно много // повторений compressed : = snappy.Encode(nil, data) decompressed, := snappy.Decode(nil, compressed) fmt.Printin("Оригинал:", string(data)) fmt.Printin("Сжатые панны? (ь байтах):", compressed) fmt.Printin("Распаковка:", string(decompressed)) fmt.Printf("Размер исходных: Id, сжатых: %d\n", len(data), len(compressed)) } В ЭТОМ примере используются функции snappy.Encode и snappy.Decode. Функция Encode принимает необязательный буфер (можно передать nil, тогда сама выделит) и исходный слайс байт ов. Она возвращает сжатый [ J byte. Аналогично Decode вос- станавливает оригинальный байт-массив Мы выводим для сравнения размеры: на- пример, строка "Hello ", повторенная 4 раза (24 байта), может сжаться Snappy до ~12-14 байтов, что вдвое меньше, а скорость выполнения будет крайне высокой. Проверяем, что decompressed совпадает с оригиналом, — Snappy, конечно, работает без потерь Размеры у Snappy зачастую больше, чем у более «тяжелых» алгоритмов, — напри- мер, gzip может ужать 24 байта "Hello..." до 10 байтов, a Snappy до 12 — разница не критична, но на больших данных суммируется. Однако скорость — ключевой фактор: Snappy стремится работать близко к пропускной способности памяти. В реальных тестах он в 3^4 раза быстрее gzip по компрессии и в -2 раза быстрее по декомпрессии, поэтому применяется, когда время дороже дискового пространства. Snappy — выбор для высокона!руженных систем, потоковой компрессии данных «на лету», когда нужно не максимально упаковать, а слетка сжать, но очень быстро. Он занимает свою нишу, противоположную Broth, там, где Brotli сжимает страни- цы для сохранения каждого байта трафика, Snappy жертвует лишними байтами, но
позволяет, например, в real-time сжимать лот-файлы или пересылать данные между серверами без заметной задержки. Алгоритмы поиска подстроки А теперь рассмотрим задачу поиска подстроки в тексте, то есть нахождения всех вхождений шаблона (паттерна) в большой строке. Прямая реализация — вложен- ный цикл сравнения каждого положения за O(n*m) (п — длина текста, m — длина шаблона) — неэффективна для больших строк. Существуют специальные алгорит- мы, позволяющие выполнять поиск быстрее. Обсудим три классических метода: ♦ Алгоритм Кнута-Морриса-Пратта (КМП) — обеспечивает поиск за линейное время O(n+m) в худшем случае за счет предварительной обработки шаблона. ♦ Алгоритм Бойера-Мура— эвристический метод, как правило, работающий очень быстро на практических текстах (среднее время близко к O(n/m)), хотя в худшем случае тоже линейный O(n+m). Известен способностью пропускать большие куски текста при несовпадениях ♦ Алгоритм Рабина-Карпа— использует хеширование для поиска подстроки. Имеет среднюю сложность O(n-irn), а возможность случайных коллизий делает худший случай O(n*m), но этот метод прост и удобен, особенно для многократ- ного поиска нескольких шаблонов (его легко расширить). Алгоритм Кнута-Морриса-Пратта Алгоритм КМП. разработанный Дональдом Кнутом, Джеймсом Моррисом и Вла- димиром Праттом, знаменателен тем, что достиг линейного времени поиска под- строки, доказав, что асимптотически быстрее невозможно для данной задачи. Глав- ная идея КМП: избегать повторных сравнений с началом шаблона при смещении по тексту. Шаблон сам по себе содержит информацию о своих префиксах и суффик- сах, которую можно использовать, чтобы резко скакнуть вперед по тексту, когда обнаружено несовпадение. КМП предварительно вычисляет для шаблона массив длины т, где n[j] — длина наибольшего собственного префикса шаблона, который одновременно является суф- фиксом его подстроки [O...j ] (то есть для подстроки, оканчивающейся в позиции j). Грубо говоря, n[j] говорит: «если на позиции j произошло несовпадение, то сколь- ко-то символов шаблона уже наверняка совпали до этого, и их не нужно проверять снова, можно начать сравнение с шаблоном с этой позиции». Построение массива п занимает О(ш). Далее алгоритм сканирует текст слева направо, поддерживая переменную j — те- кущий индекс в шаблоне (сколько символов подряд совпало). Итерируем индекс 1 по тексту: 1. Сравниваем символы text [ i ] и pattern [ j ]. 2. Если они совпали, увеличиваем j++. Если j достиг я (весь шаблон совпал) — найдено вхождение, можно зафиксировать индекс i-m+1.
3. Если символы не совпали (a j > с), то КМП не возвращается к i=j=C, как наив- ный подход, а использует грефикс-функцию: устанавливает j = пг j-11 — то есть откатывает индекс шаблона к следующему возможному совпадающему префик- су. Затем снова сравнивает text[i] с patterntj] (то есть пытается «выстроить» совпадение на основе уже известного суффикса). 4. Если j = о и несовпадение, просто двигаем i вперед. Таким образом, индекс i по тексту всегда движется вперед, не отматываясь назад, j по шаблону гоже двигается вперед, но при несовпадениях может прыгнуть назад на величину префикса В итоге каждый символ текста и шаблона будет сравнен огра- ниченное число раз — общая сложность 0(п+ш). Напишем функцию для вычисления префикс-функции prefixFunction(pattern) и сам поиск (листинг 2.12). func prefixFunction(pattern string) []int ( m := len(pattern) n := make([]int, m) n[C] = 0 k := 0 /,' длина наибольшего префикса-суффикса для текущей позиции for 1 := 1; i < m; 1++ { for k > 0 S& patternfk] != pattern[i] ( k = n[k-l] ) if patternfk] = pattern[i] { k++ } n[i] = k } return n ) func KMPSearch(text, pattern string) []int { n, m := len(text), len(pattern) if m == 0 ( return nil > n := prefixFunction(pattern) result := [|int{) j := С // текущее количество совпавших символов шаблона fcr i := 0; i < n; i++ ( for j > 0 && text[i] != pattern!j] { j = n[j-l] } if text[i] = pattern)j] (
j++ if j = m { result = append(result, i-m+1) // нашли вхождение j =n[j-l] // продолжаем поиск следующих вхождений } } } recurn result } func main() { text := "ABAABCABAABAB" pattern := "ABAAB" indices := KMPSearch(text, pattern) fmt.Printin("Найдено вхождений:", len(indices), "пс индексам:", indices) // Вывод, например: Найдено вхождений: 2 пс индексам: [2 7] } Здесь prefixFunction вычисляет массив п. Мы используем стандартный алгоритм: итерируем i от 1 до т-1, используем к как длину текущего префикса. Если символы не совпадают, k = п[к-1 J (откатываемся), пока не найдем совпадение или к не станет 0. Если совпали, увеличиваем к. Записываем п[х] = к. В итоге получаем префикс- функцию. Поиск KMPSearch идет по тексту i. Внутри никла for — сравнение, если не равны — откат j = n[j-i]. Если равны— j++. Когда j == m (допши до конца шаблона), добав- ляем индекс начала i-m+1 в результат и откатываем j = п[j-1] (чтобы продолжать искать перекрывающиеся шаблоны тоже). В примере "аваавсаваавав" шаблон "авааз" встречается дважды: начиная с индекса 2 и 7. КМП гарантирует линейное время даже на патологических данных (например, текст "ааааааааа" и шаблон "аааав"). В наивном алгоритме было бы 0(п*ш) сравне- ний, а КМП, благодаря л-функции, сдвигает шаблон на большие расстояния за один шаг. Для хранения л-функции алгоритм требует памяти О(ш). Алгоритм Бойера-Мура Алгоритм Роберта Бойера и Дж. Мура знаменит своей практической скоростью. Он использует две основные эвристики для пропуска частей текста: ♦ Эвристика плохого символа (stop-symbol). При несовпадении на позиции j шаблона с текстом можно сдвинуть шаблон сразу далеко вправо до позиции, следующей за последним таким же символом в шаблоне (или если символ во- обще не присутствует в шаблоне — сдвинуть за весь шаблон). ♦ Эвристика хорошего суффикса. Если суффикс шаблона совпал с частью тек- ста, но следующий символ не совпал, шаблон сдвигается так, чтобы этот сов-
павший суффикс выровнялся с другим таким же вхождением в шаблоне (если имеется), или с префиксом шаблона, или при отсутствии тех и других — шаблон сдвигается полностью за суффикс. Проше говоря, алгоритм Бойера-Мура начинает сравнение символов шаблона справа налево, двигая шаблон по тексту слева направо За счет этого при первых же несовпадениях он может сместить шаблон на большие шаги, пропуская провер- ки. В среднем на каждом шаге он не возвращается назад по тексту', а перескакивает несколько символов, достигая сублинейного среднего времени. Упрощенный вариант (Хорспул). На практике часто реализуют упрощенный ал- горитм Бойера-Мура-Хорспула, использующий только эвристику плохого символа (без хорошего суффикса). Он работает очень хорошо на случайных текстах, хотя худший случай деградирует до O(n*m). Полный алгоритм с обеими эвристиками гарантирует худшее О(п+ш), но сложнее в реализации Здесь мы сначала приведем более простой вариант (ВМ Horspool), а затем поясним идеи полного ВМ. Реализация состоит из подготовки таблицы смещений для плохого символа и ос- новного цикла поиска (листинг 2.13). Листинг 2.13 func ooyerMocreSearch(text, pattern string) []int { n, m := len(text), len(pattern) if m == 0 { return nil ) // Таблица смещений: по ^-молчанию для всех символов = m shift := make(map[byte]int) for i := 0; i < 256; i++ { shift[byte(i)j = m } // Для каждого символа шаблона (кроме последнего) // вычисляем смещение for j := С; j < m-1; j++ { shift[pattern[j]] = m - 1 - j } result := []ir.t{} i := 0 // индекс e тексте for i <= n-m { j := m - 1 // сравнение шаблона с текста справа налево for j >= 0 && pattern!j] == text[i+j] { j“ } if j < 0 { result = append(result, 1) 11 совпадение найдено i++ // сдвигаем на 1 для поиска следующего (можно Il сдвинуть на т, но тогда пропустим перекрытия)
} else { // сдвиг по таблице для "плохого символа" text[i+j] i += shift[text[i+j]] } } return result } Таблица shift размером по числу возможных символов (здесь 256 для байтов) — указывает, на сколько сдвинуть шаблон, если при сравнении последний несовпав- ший символ текста равен определенному значению. Мы инициализируем все сим- волы значением m (если символ не встречается в шаблоне, можно сдвинуть шаблон полностью за него). Затем дгя каждого символа шаблона, кроме последнего, запол- няем shift [pattern [j ] ] = m-1-j. Это соответствует следующему подходу: если не- совпадение произошло на символе шаблона с индексом j (считая оз 0 слева), и этот символ в тексте = patterntj] (плохой символ), то сдвинуть шаблон так. чтобы его символ patterntj] оказался под этим же символом при последнем его появлении в шаблоне справа. А самая правая позиция pattern!jl в шаблоне — это индекс j, по- этому сдвиг = (m-1) - j, чтобы эта позиция выровнялась с позицией . шаблона под текущим символом текста. Основной цикл: начинаем проверять с позиции i=o текста. Берем j с конца шаблона (т-1) и движемся влево, пока символы совпадают. Если дошли до j < о, значит, всё, шаблон совпал — добавляем i в результаты Если же нашли несовпадение на позиции j, то сдвиг = shift [text [i+j] ]. Например, если на позиции i+j оказался текстовый символ, которого нет в шаблоне, shift = m, — мы сдвинем шаблон полностью за этот символ (пропустив m позиций). Если символ есть, скажем, ’А’, и в шаблоне последняя 'А' находится ближе к концу, го сдвиг может быть небольшим. Переменная i увеличивается на shift [...], и цикл продолжается. Мы сдвигаем шаблон в этой реализации гак, чтобы потенциально перекрывающие- ся вхождения тоже нашлись, — когда находят совпадение, я увеличил i на 1 (в тео- рии можно сдвинуть на m для неперекрывающих ся, но вдруг шаблон сам себя пере- крывает, лучше 1). В результате получим список всех индексов вхождений. Эта реализация— вариант Хорспула Ее сложность в худшем случае— O(n*m) (например, когда текст "ааааааа" и шаблон "чаааа", алгоритм будет продвигаться по 1-й позиции часто). Но на случайных текстах этот вариант работает очень быстро — шаблон, как правило, не часто «почти совпадает» и сдвигается большими шагами. Добавление эвристики хорошего суффикса усложняет подготовку (нужно вычис- лить таблицу для сдвигов по суффиксам). Смысл: если суффикс длины к шаблона совпал с текстом, а предшествующий символ не совпал, ищем в самом шаблоне справа налево первое другое вхождение этого суффикса, чтобы выровнять его (или
префикс, если суффикс также является префиксом). Это позволяет иногда сдвигать дальше, чем эвристика плохого символа В комбинации эти две эвристики делают алгоритм линейным в худшем случае и чрезвычайно быстрым в среднем. На практике часто бывает так: если текст не «враждебный» специально, ВМ рабо- тает быстрее всех. Поэтому его называют алгоритмом по умолчанию для поиска подстрок Например, поиск в утилите grep или git grep реализован на основе вари- антов алгоритма Бойера-Мура, потому что типичные текстовые данные (коды, анг- лийский текст) дают хорошие отступы. Пусть текст: "somestring", шаблон: "string". Бойер-Мур начнет сравнивать с конца; somestring string Сразу видим, последний символ шаблона 'д' 1= 't' в тексте, 't*-—плохой символ, он есть в шаблоне на позиции 4 (от начала). Значит, сдвиг = т-1-4 = 6-1-4 = 1 по таб- лице. Шаблон сдвигается на 1 вправо и встает под конец текста: somestring string Теперь сравнение: 'д' vs 'д' (последние совпали), идем влево: 'п' vs 'п', ..., весь шаблон совпал — нашли вхождение на 1=4. Алгоритм проверил всего 2+6=8 симво- лов вместо 6*10=60 в наивном случае — выигрыш очевиден. Это небольшой при- мер, но па больших строках ВМ может пропускать целые блоки сразу. Алгоритм Рабина-Карпа Алгоритм Рабина-Карпа использует технику хеширования для поиска строки. Мы заранее считаем короткий числовой отпечаток (хеш) для шаблона и для каждого окна текста той же длины. Дальше сравниваем не сами строки, а их отпечатки: не совпали — окно сдвигаем дальше, совпали — делаем быструю проверку «на всякий случай», что под окном действительно лежит нужная подстрока. При нормальном подборе параметров такие совпадения почти всегда означают реальный матч, по- этому суммарное время близко к линейному. Фокус в «скользящем хеше»: представляем текущее окно как большое число в не- кой базе (для байтов удобно думать «256 ричная счетка»). Сдаю аясь на один сим- вол вправо, мы быстро обновляем отпечаток вычли вклад ушедшего слева симво- ла, мысленно сдвинули число на разряд и добавили новый правый символ. Все вы- числения держим в пределах аккуратного модуля, чтобы числа не распухати. Итог —- переход к следующему окну обходится постоянным временем. На практике важны детали: хорошая база и модуль резко уменьшают шанс ложных совпадений, а для совсем параноидальной надежности берут два независимых хе- ша. Рабин-Карп хорош, когда нужно искать много шаблонов или быстро прогонять гигантские тексты. Выберем, например, В=256, М=1е9-7 (что-то крупное). Найдем все вхождения шаблона в тексте (листинг 2.14).
—-------------------------------------------------------------------------------------------------------------------°— - —— Листинг 2,14 «ь-.й - ------- - -----------------------------------------* —- ......-'У;-:.. ........ -............ . ----У-.. .Й func rabinKarpSearch(text, pattern string) []int { n, m := len (text), len(pattern) if m == 0 || m > n { return nil ) base := 256 mod := 1000000007 // Вычисляем base'(m-1) med для удаления старшего разряда var hignPow = 1 for i := 0; i < m-1; i++ { highPow = (highPow * base) % mod ) // Вычисляем хеш шаблона и первый хеш окна текста var hashP, hashT int for i := 0; i < m; i++ { hash? = (hashP*base + int(pattern[i])) % mod hashT = (hashT*base + int (text[i])) % mod } result := []int{} for i := 0; i <= n-m; i++ { if hashP ==• hashT ( // Возможное совпадение, проверим символы if text[i:i+m] == pattern { result = append(result, i) } } if i < n-m ( // Вычисляем хеш для следующего окна, // удаляя textfi] и добавляя text[i+m] hashT = (hashT - int (text [i]) *highPow) % mod if hashT < 0 ( hashT += mod } hashT = (hashT*base + int(text[i+m])) % mod } ) return result Здесь highPow = base'(m-i) mod mod. Начальные хеши nash? (partem) и hashT (первые m символов текста). Цикл i от 0 до n-m: если хеши равны, делаем точную проверку срезов (это линейно по т, но случается редко, при хорошей хеш-функции).
Затем скользящим образом обновляем haahT: вычитаем старший символ, домножа- ем, добавляем новый. Обязательно делаем if hash? < о {hashT += mod} после вычита- ния, чтобы не было отрицательных остатков (в Go % может вернуть отрицательное). Сравнение с pattern через строковое сравнение — здесь на О(т) в случае совпаде- ния хеша. При хорошем выборе mod вероятность коллизии порядка 1/mod. Для mod ~1е9 это менее 1е-9 — пренебрежимо. Таким образом, почти никогда не потребуется ложно- ю сравнения. Алгоритм Рабина-Карпа особенно удобен, сели мы хотим найти лю- бой из многих шаблонов: можно, например, посчитать хеш для каждо е из К шаб донов и искать совпадение хеша в тексте— тогда средняя сложность О(п + К). Также на основе принципа Рабина-Карпа строятся двухмерные поиски: подматри- цы в матрице, поиск плагиата (складывание по скользящему окну абзаца) и т. д. Возьмем текст "abcdabcab" и шаблон "abc" Вычисляется хеш "abc", затем идем по тексту: 1. i=0: сравниваем xem("abc"j с xemfabc") — совпали, проверяем строку да, ин- декс 0. 2. Потом i=l: xem("bcd") скорее всего не равен, пропускаем. 3. i=4: хешСаЬс") совпал, проверяем — да, индекс 4. 4. и т. д. Итого найдены вхождения в позиции 0 и 4. Рабин-Карп по эффективности сравним с КМП в среднем. Он может быть чуть медленнее из-за вычислений хеша, но иногда проще в реализации. В худшем слу- чае (например, текст "ааааааай", паттерн "ааа") КМП сделает ~2п операций, а Ра- бин-Карп— при плохом mod может каждый раз иметь равный хеш и каждый раз проверять строку, что даст ~n*m. Но с хорошим mod это практически не случается. Для гарантии лучше использовать КМР или полный Бойер-Мур. КМР дает твердое 0(n+m) время, ВМ — очень быстрый в среднем, а с двумя эвристиками — тоже 0(n+m) в худшем. Рабин -Карп удобен для многократных и мультимодул ьных за- дач (и прост для понимания). Зная эти алгоритмы, можно уверенно решать задачи поиска в тексте — от простых функций вроде strings. index до построения утилит наподобие grep или механизмов поиска по базам данных. Все эти алгоритмы являются базовыми строительными блоками всех программ. Алгоритмы кратчайших путей Пришло время перейти к одной из самых классических и практически востребо- ванных задач на графах — поиску кратчайших путей. Именно эту задачу решает ваш навигатор, когда строит маршрут, именно она стоит за маршрутизацией паке- тов в Интернете и оптимизацией логистики. Разберемся, как она формулируется и какие алгоритмы существуют для ее решения.
Постановка задачи. Пусть дан взвешенный граф G=(V,E) с функцией весов w(u,v) для ребер. Требуется найти путь минимального суммарного веса между двумя вер- шинами. Задачу можно ставить по-разному: искать путь от одной конкретной вер- шины до другой, от заданного истока до всех остальных или сразу между всеми парами вершин. Мы сосредоточимся на первых двух вариантах — алгоритмы для них схожи. Для задачи «все пары» обычно применяют другие методы — например, алгоритм Флойда-Уоршелла. Ориентированные и неориентированные графы. Граф может быть ориентиро- ванным (движение возможно только в направлении дуг) или неориентированным (ребро можно проходить в обе стороны). Для кратчайших путей это различие не- принципиально: неориентированное ребро можно рассматривать как две дуги с одинаковым весом в противоположных направлениях. Веса ребер. Выбор алгоритма во многом зависит от свойств весов. Если все веса неотрицательны — задача проще и допускает более быстрые решения С отрица- тельными весами сложнее. Сами по себе отрицательные веса не мешают найти кратчайший путь. Но если в графе есть достижимый цикл с отрицательной суммарной длиной, поня- тие «кратчайший путь» теряет смысл — можно бесконечно уменьшать вес, проходя по циклу снова и снова. Формально говорят, что длина кратчайшего пути равна -оо. Хорошие алгоритмы умеют такие ситуации обнаруживать. Итак, задача ясна. Теперь рассмотрим три ключевых алгоритма; Дейкстры — для графов без отрицательных ребер, Беллмана Форда — для обшего случая с обна- ружением отрицательных циклов и А* — для ускоренного поиска пути до конкрет- ной цели с помощью эвристики. Алгоритм Дейкстры Это один из самых известных жадных алгоритмов в информатике, названный в честь нидерландского ученого Эдсгера Дейкстры. Алгоритм находит кратчайшие расстояния от заданной стартовой вершины s до всех других вершин графа, при условии что все веса ребер неотрицательны. Если нужно расстояние только до одной цели, алгоритм можно остановить, когда целевая вершина будет извлечена, — но в базовом варианте он строит дерево кратчайших путей до всех. Идея алгоритма. Дейкстра — жадный алгоритм, расширяющий область известных кратчайших путей исходя из начальной вершины. Шаги можно описать так: 1. Инициализация: расстояние до стартовой вершины s полагаем 0, до остальных — зо (неизвестно). Множество посещенных (обработанных) вершин пусто. 2. На каждом шаге выбирается необработанная вершина с минимальным теку- щим расстоянием. Сначала это будет сам s (0, минимально). Назовем эту вер- шину а и поместим ее в множество посещенных 3. Из вершины а пытаемся улучшить оценки расстояний до ее соседей v: для каж- дого ребра (u.v) выполняем релаксацию — проверяем, меньше ли путь dist[u] +
w(u,v) текущего известного dist[v] Если да — обновляем distfv] = distfu] + w(u,v). 4. Повторяем шаг 2: снова выбираем среди непосещенных вершину с наименьшим dist[] (ближайшую из оставшихся). Релаксируем ее ребра и отмечаем как обра- ботанную. 5, Процесс продолжается, пока не будут обработаны все достижимые вершины (или до достижения целевой вершины, если цель одна). Почему этот жадный выбор корректен? Ключевая лемма Дейкстры утверждает: ко- гда мы извлекаем вершину и с минимальным расстоянием, это расстояние уже окончательно и оптимально. Ведь любой путь к и через другие необработанные вершины был бы длиннее (так как и — ближайшая). Значит, после извлечения и можно смело закрепить ее расстояние. Поиск минимальной вершины. Простая реализация выбирает минимальную вер- шину линейным поиском по массиву расстояний (среди непосещенных). Это дает работу O(V) на каждый шаг и общую сложность O(VA2 + Е) (для плотных графов ~O(VA2)). Однако можно ускорить выбор, используя структуру данных — приори- тетную очередь (min-heap). Храня пары (расстояние, вершина) в куче, мы можем получать ближайшую вершину за O(log V). При этом каждое ребро обрабатывается при релаксации, фактически добав ляя запись в очередь, что дает О(Е log V) на всю работу с ребрами. Итого сложность такой версии — O((V+E) log V), что для разре- женных графов близко к О(Е log V). В работе же чаще всего используют именно версию с кучей, гак как <рафы обычно имеют меньше ребер, чем VA2 Рассмотрим небольшой пример; ориентированный граф, неогрицательные веса (рис. 2.1). Рис. 2.1 Ребра; 0—>1 (вес 1), 0—>2 (4), 1—>2 (2), 2—>3 (1), 1—>3 (5). Стартуем из вершины 0. Инициализация: dist = |0. ос, оо, ос]. Шаги алгоритма Шаг 1: ближайшая не посещенная = 0 (dist=O). Обрабатываем ее соседей: 1 через 0 дает 1, 2 через 0 дает 4. Обновляем dist = [0, 1,4, <х]. Посещена О, Шаг 2: ближайшая из непосещенных = 1 (dist=l). Обрабатываем: 2 через 1 = 1+2=3, улучшаем dist[2] с 4 до 3; 3 через 1 = 1+5=6, dist[3]=6. dist = [0,1,3,6]. Посещена 1.
Шаг 3: ближайшая = 2 (dist=3). Обрабатываем: 3 через 2 = 3+1=4, улучшение dist[3] с 6 до 4 dist = [0,1,3,4]. Посещена 2. Шаг 4. ближайшая = 3 (dist=4). Обрабатываем ее (у нее нет исходящих или уже обработаны). Посещена 3, алгоритм завершен. Результат: кратчайшие расстояния от 0 — это [0. 1, 3, 4]. Действительно, путь 0—»1—»2—*3 весом 1+2+1=4 оказался оптимальным до вершины 3. что и нашел ал- горитм. Псевдокод алгоритма Дейкстры приведен в листинге 2.15. Листинг 2.15 j Dijkstra(G, s) : dist[v] : = “ для всех v 6 V dist[s] := 0 Создать мин-кучу H и вставить (0, s) Пока Н не пуста: (d, u) ;= extractMin(H) // вершина и с наименьшим // расстоянием d Если d > distfu] : продолжить // устаревшая запись Для каждого ребра (u, v) £ Е: если dist[u] + w(u,v) < dist[v]: dist[v] := dist[u] + w(u,v) insert(H, (dist[v], v)) вернуть dist[] Здесь мы пропускаем устаревшие записи из очереди (если найденное расстояние больше актуального dist [и), значит, вершина и уже обработана с меньшим расстоя- нием). После завершения алгоритма массив dist содержит минимальные расстоя- ния от s до всех достижимых вершин. Если какая-то вершина осталась с ос — зна- чит, она недостижима из s. Ограничения. Алгоритм Дейкстры не работает с отрицательными весами ребер. Если хотя бы одно ребро имеет отрицательный вес, жадный выбор может оказаться неверным. Простой пример: две вершины А и В, между ними два ребра: прямое А—>В вес 10 и цикл А -»С—43 с весами 5 и -7 (сумма -2). Дейкстра начнет с А, взяв прямое ребро 10, решит, что расстояние до В равно 10, и пометит его обработан- ным. Но потом цикл мог бы дать путь весом -2, которого алгоритм уже не увидит, так как В помечен. Поэтому, если в задаче есть отрицательные веса — нужно ис- пользовать другой метод (Беллмана-Форда или алгоритмы с потенциалами).
Сложность. Как упоминалось, при реализации с очередью сложность — O((V+E) log V). Если граф плотный (Е - VA2), то это приближается к O(VA2 log V). В реали- зации без очереди (двойной цикл по V) сложность O(VA2 + E)=O(V'2) — для очень плотных графов она даже может оказаться чуть лучше, но в среднем случае с оче- редью эффективнее. Реализация алгоритма Дейкстры на (io приведена в листинге 2.16. Используем стандартную библиотеку container/heap для приоритетной очереди (минимальной кучи). Будем хранить расстояния в слайсе dist и писать тип item для элементов оче- реди (пара вершина + текущая дистанция). Питтинг Z1S oackage main import ( "conearner/heap" "fmt" "math" ) // Item - элемент очереди (кучи) дуре Item struct { node int dist int } // PriorityQueue реализует кучу элементов Item (минимальная пс dist) type PriorityQueue ГjItem func (pg PriorityQueue) Len() int { return len(pq) } func ;pq PriorityQueue) Less(i, j int) bool { return pqli].dist < pq[j].dist } func (pq PriorityQueue) Swap(i, j int) { pq(i], pq[j] = pq[j], pq[i] } func (pq PriorityQueue) Push(x interface{}) { pq = append(* *pq, x.(Item)) ) func (pq *PriorityQueue) Pop() interface)) { old := *pq n := len(old) item := old)n-lj *pq = old[:n-l] return item } Il Функчия для вычисления кратчайших расстояний из истока s. // graph представляется списком смежности: graph[и] = список (v, вес). func Dijkstra (graph [] [] [2]int, s int) []int { n := len(graph) dist := make([]int, n) for i := range dist { dist[ij = math.Max!nt32 // "бесконечность" }
dist[s] = О pq := SPriorityQueue{} neap.Init(pq) heap.Push(pq, Item[node: s, dist: 0)) for pq.Lenl) > 0 ( item := heap.Pop(pq) . (Item) u := item.node d := item.dist if d != distfu] { continue // устаревшая запись ) // Релаксируем ребра из и for edge := range graph[и] { v, w := edge[0], edge[l] if dist[u]4w < dist[vl { dist[v] = dist | u] + w neap.Push(pq, Item(node: v, dist: dist[v]}) } ) ) recurn dist func main() ( Il Задаем граф из приведенного ранее примера graph := make([] [] [2]int, 4) graphfO] = append(graph[0], [2]int[l, 1}) // 0->l (1) graphJO] = append(graph[0], [2]int{2, 4]) // 0->2 (4) graph[l] = append(graph[1], [2]int(2, 2}) // l->2 (2) graphfl] = appena(graph[1], [2]int{3, 5)) // l->3 (5) graph(2] = append(graph[2], [2]int{3, 1)) // 2->3 (1) 11 graph[3] исходящих нет dist := Dijkstra(graph, 0) fmt. Printin ("Кратчайшие расстояни;- от 0:", dist) Il Ожидаемый вывод: [0134] Здесь мы используем срез пар [2]int{v, w} для представления ребра (целевой узел и вес). При запуске этого примера получим результат: Кратчайшие расстояния от 0: [0134] соответствующий расчету вручную. Тип PriorityQueue И методы Len, Less, Swap, Push, Pop необходимы ДЛЯ использования heap, interface. Мы храним вес кандидаты, даже если некоторые устаревают — про- верка if d != dist[u] { continue } отфилыровываег липшие.
Это такой базовый прием для упрощения кода: можно было бы обновлять ключи в куче, но в Go container/heap не дает прямого метода уменьшения приоритета, по- этому проще допускать дубли. Алгоритм Дейкстры таким образом и реализован Алгоритм Беллмана-Форда Для графов с отрицательными весами алгоритм Дейкстры не подходит. Здесь поможет алгоритм, разработанный независимо Ричардом Беллманом и Лес- тером Фордом в конце 1950-х. Алгоритм Беллмана-Форда способен найти крат- чайшие пути даже если веса отрицательные (при условии отсутствия циклов отри- цательного веса). Более того, он может обнаружить факт существования отрица- тельного цикла, достижимого из стартовой вершины. Идея алгоритма. Беллман-Форд опирается на динамическое программирование и принцип релаксации ребер. Представим,, что в наихудшем случае путь до любой вершины может содержать максимум |V|-1 ребро (больше бы означало цикл). Тогда можно пытаться итеративно улучшать оценки расстояний, рассматривая пути воз- растающей длины. Проше говоря: 1. Сначала учтем пути длины 1 (прямые соседи истока). 2. Затем пути длины 2, 3 и так далее, до |V|-1. Алгоритм делает |V]-1 итерации и на i-й итерации релаксирует все ребра графа. Релаксация — та же операция обновления dist[v] = min(dist[v], disrfu] + w(u,v)) для каждого ребра (u,v). После к итераций гарантируется, что найдены кратчайшие пу- ти, содержащие нс более к ребер. После |V|-1 итераций — кратчайшие пути, содер- жащие до |VI-1 ребра, то есть все возможные простые пути. Дополнительно выполняют |V|-ro итерацию для проверки: если на этом шаге еще происходит улучшение расстояния для какого-то ребра — значит, существует цикл отрицательного веса (потому' что нормальные кратчайшие пути уже учтены, улуч- шение может произойти только за счет обхода цикла). Таким образом, можно де- тектировать отрицательный цикл. Описание шагов: Инициализация: как обычно, distfs] = 0, остальные = со. Повторить V-1 раз: 1. Для каждого ребра (u,v) в графе: если dlst[u] -1- w(u,v) < dist[v], обновить dist[v] = dist[u] + w(u,v). 2. После этого dist[] — кандидаты на кратчайшие расстояния. Д/тя безопасности делаем еще одну итерацию по ребрам: 3. Если найдется ребро (u,v), для которого всё еще выполнено distfu] + w(u,v) < dist[v], значит, обнаружен отрицательный цикл (расстояния можно уменьшить бесконечно). В таком случае можно зафиксировать факт наличия отрицательно- го цикла (например, бросить ошибку или отметить соответствующие вершины как имеющие расстояние -оо).
Доказательство алгоритма основывается на индукции до длине пути: после к про- ходов все кратчайшие пути длиной < к учтены. Ограничение в |V|-1 шагов возника- ет, потому что простой путь ("без повторения вершин) максимальной длины содер- жит |V|-1 ребро. Алгоритм Беллмана-Форда проходит по всем ребрам (Е) |V|-1 раз, итого O(V * Е) операций. Для разреженных графов (мало ребер) это близко к O(V) на итерацию, но для плотных O(VE) ~ O(VA3) — что существенно медленнее Дейкстры. Например, если V-1000 и Е ~ 100k, Дейкстра справится гораздо быстрее (100k log 1000 « 1000100k). Поэтому алгоритм применяют, только когда он необходим (при наличии отрица- тельных весов или для распределенных задач). Кстати, его плюс — простота парал- лели зации и распределенной реализации (он не жадный, а локально обновляет веса, что похоже на рассылку обновлений по графу). Одно из применений Беллмана-Форда — сетевые протоколы маршрутизации типа RIP, где узлы обмениваются информацией о .лучших расстояниях (Bellman-Ford в распределенной форме). Но основной его плюс в алгоритмических задачах — способность обнаружить от- рицательные циклы. Реализация на Go приведена в листинге 2.17 Будем хранить граф как список ре- бер (это удобнее для прямого перебора). Каждое ребро зададим структурой с поля- ми u, v, w. Алгоритм будет возвращать слайс расстояний и булев флаг hasNegCycie, сигнализирующий о наличии отрицательного цикла. package main import ( "fmt" "math" ) type Edge struct { u, v int w int } func BellmanFord(edges []Edge, n, s int) ([]int, bool) { dist := make([]int, n) for i := range dist { dist[i] = math.Max!nt32 ) dist[s] = 0 // relax edges V-l times for i := 0; i < n-1; i++ { updated := false for , e := range edges {
if di st [е.u] != math.MaxTnt32 &i dist [e.u]-t-e.w < distfe.v] { dist[e.v] = dist[e.u] + e.w updated = true } } if lupdated { break // ускорение: выходим раньше, // если ничего не изменилось } } // check for negative cycle hasNegCycie := false for e := range edges { if dist[e.u] ' = math.MaxInt32 &S dist [e.u]re.w < dist[e.v] { hasNegCycie = true break } } return dist, hasNegCycie ) func main() { // Пример графа с отрицательным ребром, но без отрицательного цикла // Вершины: 0->1 (1), 0->2 (4), 1->2 (-2), 1->3 (2), 2->3 (3) edges := []Edge{ {0, 1, 1), // 0->1 (1) {0, 2, 4), И 0->2 (4) {1, 2, -2), и 1->2 (-2) {1, 3, 2}, // 1->3 (2) {2, 3, 3), // 2->3 (3) } n := 4 dist, negCycle := BellmanFord(edges, n, 0) if negCycle { f.T.t. Printin ("Обнаружен отрицательный цикл!") } else { fjnt.Printin ("Кратчайшие расстояния от 0:", dist) } // Ожидаемый вывод: Кратчайшие расстояния от 0: [01-11] Граф содержит отрицательное ребро 1—>2 (-2), но цикла отрицательного нет. Крат- чайшие пути от 0: напрямую до 1 (вес 1), до 2 выгоднее через 1 (0—>1—>2 вес 1 + (- 2) = -1), до 3 оптимально 0—>1 —*3 (вес 1+2=3) или 0—>1—>2—>-3 (1 + (-2) + 3 = 2) — второй вариант лучше с весом 2. Значит dist = [0, 1,-1, 2]. Запустив код, получим именно эти значения.
Несмотря на большую теоретическую сложность, Беллман-Форд часто отрабатыва ет быстрее на практических данных, если граф не слишком плотный, и улучшения происходят не во всех итерациях (мы даже включили проверку updated для прежде- временного выхода). Однако для действительно больших фафов с десятками тысяч ребер его использование оправдано, лишь когда без него нельзя, — например, для обнаружения отрицательных циклов. Алгоритм А*: эвристический поиск Алгоритмы Дейкстры и Беллмана-Форда находят пути от одного источника до всех узлов. Но часто нас интересует путь только до конкретной цели t. Если граф очень большой, то поиск до всех вершин — избыточная работа. Алгоритм А* (произносится «А звездочка») позволяет значительно ускорить поиск одного кратчайшего пути, используя дополнительную информацию о структуре задачи — эвристику. Основная идея А*. Это по сути алгоритм Дейкстры, дополненный эвристической оценкой расстояния до цели. Введем функцию h(v) — эвристическое приближение расстояния от вершины v до финиша t. Например, если вершины имеют координа- ты на плоскости, и веса ребер отражают расстояние, то естественная эвристика — прямое (евклидово) расстояние от v до t или манхэттенское расстояние (если дви- жение по сетке). А* хранит для каждой вершины два значения. ♦ g(v) — уже известное наименьшее расстояние от старта s до v (как dist раньше). ♦ h(v) — эвристика от v до цели (это задается проблемой, например, для сетки — манхэттенское расстояние). Алюригм оперирует суммой f(v) = g(v) + h(v) — - это предполагаемый полный путь через v (известный путь до v плюс опенка оставшегося до цели). На каждом шаге А* выбирает для расширения вершину с наименьшим значением f(u) (вместо про- сто g(u) как в Дейкстре) Таким образом, поиск предпочитает те пути, которые с вероятностью ведут к цели быстрее всего. Эвристика дает алгоритму такую вот чуйку, где лежит цель, и он меньше тратит время на исследование заведомо бес- перспективных направлений. Чтобы А* гарантированно нашел оптимальный путь, эвристическая функция долж- на быть допустимой — не переоценивать расстояние до цели. Попросту говоря, h(v) никогда не должна быть больше фактического кратчайшего расстояния от v до t. Если это условие выполнено. А’ найдет кратчайший путь. Если, кроме того, эв- ристика удовлетворяет условию монотонности (или согласованности) — то алго- ритм не посещает повторно вершины с лучшими значениями (упрощаются доказа- тельство и реализация). Например, эвристика на основе расстояния «по прямой» в метрическом простран- стве обычно допустима и монотонна. Если же эвристика завышает расстояния (неадекватно оптимистична), А* может пропустить оптимальный путь (фактически превратится в жадный поиск, который
не гарантирует оптимальности). С другой стороны, если эвристика слишком зани- жена (например, ноль для всех вершин) — А* вырождается в обычный Дейкстра без вьппрыша. В общем, хорошая эвристика должка быть достаточно информатив- ной, но не превышать реальность. Примеры эврист ик: ♦ Манхэттенское расстояние: для решетки (квадратный город с улицами) опенка пути = |Дх| + | Ду| между текущей позицией и целью. Применимо для навигаторов. ♦ Евклидово расстояние: «по прямой» т/((х]-х2)Л2 + (у1--у2)л2). Подходит, когда путь может быть приблизительно прямолинейным (например, полет пти- цы). Используется дтя маршрутов на плоскости (учитывая, что реальный путь не короче прямой линии). ♦ Картографические специальные: если расстояния на сфере — можно взять длину дуги (но кругу) между двумя координатами. ♦ Для игровых карт: иногда строят подграф из «ориентиров» и заранее считают между ними или используют грубую сетку для оценки — все зависит от контекста. Пример работы Л*. Рассмотрим сетку 5><5, где разрешены ходы вверх, вниз, влево и вправо с весом 1, а цель находится в правом нижнем углу Манхэттенская эври- стика = (Дх + Ду) даст направление к цели. А* будет преимущественно развивать узлы, лежащие ближе к цели по этой метрике, игнорируя отдаленные — в итоге количество проверенных узлов может быть в разы меньше, чем у Дейкстры, кото- рый расширяет волны во все стороны. На графах выигрыш очень большой— на- пример, на задаче маршрутизации дорог' в городе с сотнями тысяч перекрестков Дейкстра посмотрел бы десятки тысяч узлов во всех направлениях, а А* только ты- сячи вдоль направленного коридора. Сложность. В худшем случае (плохая или нулевая эвристика) А* переберет столь- ко же узлов, что и Дейкстра. — сложность остается экспоненциальной относитель- но длины пути или О(Е log V) технически. Но в среднем, при хорошей эвристике, А* значительно сокращает поиск. Считается, что А* никогда не работает дольше, чем Дейкстра с той же структурой (эвристика h=0 даег равную работу, любая по- лезная эвристика — только уменьшает количество посещений). Реализация на Go приведена в листинге 2.18. Реализуем алгоритм А* для задачи поиска пути в сетке. Будем задавать старт, цель, а также эвристическую функцию h(x,y) — манхэттенское расстояние до цели (x goal, y goal). В реализации исполь- зуем структуру для состояния, учитывающего и g, и h. package main import ( "container/Пеар" "fmt" "math"
// Структура клетки с координатами type Cell struct { х, у int ) // Структура для элемента очереди А* type State struct t cell Cell g int // прейденное расстояние от старта f int // g + h } Il Реализация кучи приоритетов no f-value type StatePQ []State func (pc StatePQ) Len() int { return len(pq) } func (pq StatePQ) Less(i, j int) bool { return pqfij.f < pq[j].f } func (pq StatePQ) Swap(i, j int) { pq[i], pq[j] = pqij], pa[i] } func (pq StatePQ) Push (x interfaced) { pq = append (’pq, x. (State)) } func (pq *StatePQ) Pop() interfacet} ( old := *pq n := len(old) item := old(n-l) *pq = cld[:n-1] return item } Il Функция А’ для сетки: grid - матрица 0/1 (0 - проходимо, 1 - препятствие) func AStar(grid [] []int, start, goal Cell) (int, bool) { rows := len (grid) cols := len(grid[0]) // Проверка: если старт или цель непроходимы if grid[start.x][start.у] != 0 I I gridigoal.x][goal.у] != 0 ( return rrath.MaxInt32, false } // Манхэттенская эвристика h := func(c Ceil) int ( return abs (c.x-goai.x) + abs(c.y-goal.y) } // distMatnx хранит мипималы:ыи g, достигнутый для клетки distMatrix := make( [] []int, rows) for i : = 0; i < rows; 1++ { distMatrix[i] = make([]int, cols) for j := 9; j < cols; j++ { distMatrix[i][j] = math.MaxInt32 } } distMatrix[start.x][start.y] = 0 // Куча для состояний pq := £StatePQ{) heap.Init(pq)
heap.Push(pq, State{cell: start, g: 0, f: h(start)}) // Направления движения по сетке dirs := []Cell{(1,0), {-1,0}, (0,1}, {0,-1}} for pq.Ler. () > 0 { state := heap.Pop(pqi.(State) u := state.cell g := state.g // Если извлекли цель - найден кратчайший путь if u == goal { return g, true } // Если текущее g больше записанного, пропускаем if g > distKatrix[u.x][u.y] { continue ) // Расширяем соседей for _, d := range dirs { v := Cell'u.x + d.x, u.y + d.y} // Проверяем границу и препятствия if v.x < 0 v.x >= rows v.y < 0 v.y >= cols grid[v.x][v.y] != 0 { continue } newG :- g + 1 // вес каждого шага = 1 if newG < distMatrix[v.x][v.y] { distMatrix[v.x][v.y] = newG heap.Push(pq, State{cell: v, g: newG, f: newG + h(v)}) return math.Max!nt32, false // путь не найден tunc abs(a int) int { if a < 0 { return -a } return a func main() { // Простая карта 5x5 с препятствием grid := [] []int{ {O,O,C,O,C}, {0,1,1,0,01, {0,1,0,0,01, {0,1,0,1,01, {0,0,0,1,01, start := Cell{0,0} goal := Cell{4,4} dist, found := AStar(grid, start, goal)
if found ( fmr.Printin("Длина кратчайшего пути:", dist) } else { fmt.Printin("Куто не найден") ) ) Здесь grid — матрица, где 1 обозначает препятствия, а 0 — свободные клетки. Старт — верхний левый угол (0.0), цель — нижний правый (4,4). Построены стены (1) на некоторых клетках А* должен обойти препятствия. Манхэттенская эвристи- ка направляет поиск к цели, но учитывает, что степы придется обходить. Если запустить программу, она должна вывести: Длина кратчайшего пути: 8 (Предположительно оптимальный маршрут обойдет препятствия и пройдет 8 шагов.) Отличие алгоритма лишь в выборе элемента из очереди (по f вместо g) и в добавле- нии эвристики при вычислении f. Все остальное — контроль уже посещенных со- стояний (diatMatrix) и релаксация — аналогично. При корректной эвристике А* всегда найдет кратчайший путь, при этом обычно рассматривая значительно мень- ше узлов, чем Дейкстра (особенно на больших сетках или графах). Примеры использования алгоритмов кратчайшего пути Навигационные системы. Обычно сеть дорог моделируется графом: перекрестки — вершины, участки дорог — ребра с весами (например, время проезда или расстоя- ние). Для городского маршрута очень хорош алгоритм А*: за эвристику берут пря- мое расстояние до цели (или с учетом скорости — время прямым путем). В особо больших масштабах применяют иерархические оптимизации (разбиение на регио- ны. предварительное вычисление отсечений). Компьютерные игры. В шрах, где персонажи должны перемещаться по карте (стратегии, RPG), задача поиска пути возникает постоянно. Игровое поле часто представляют сеткой или графом областей, и классический выбор — алгоритм А*. Он достаточно быстрый и находит оптимальные или почти оптимальные пути для персонажей. Например, герой в лабиринте будет искать путь к выходу: если ис- пользовать просто DFS/BFS, персонаж может «тупить», обходя лишние углы, а А* с эвристикой будет идти прямо к цели, огибая преiрады по необходимости. В игро- вых движках часто реализованы варианты А*, и разработчики могут настраивать эвристику или делать ее слегка недопустимой для более реалистичного движения (тогда путь не строго кратчайший, но технически быстрее). Маршрутизация в сетях. Протоколы маршрутизации Интернета также решают задачи кратчайшего пути, хотя и в распределенном виде. Протокол OSPF использу- ет алгоритм Дейкстры: каждый маршрутизатор вычисляет кратчайшие пути в сети до всех остальных, считая «стоимость» связей. Протокол RIP (Routing Information Protocol), напротив, основан на алгоритме Ьеллмана-Форда (distance-vector): узлы
периодически обмениваются информацией о лучшем расстоянии, сходясь пример- но к решению Беллмана-Форда. Другие применения: кратчайшие пути возникают в логистике, в анализе социаль- ных графов, в проектах типа навигации робота-пылесоса по квартире и в некоторых задачах биоипформатики. Сравнение алгоритмов кратчайших путей Итоги сравнения алгоритмов сведены в табл. 2.1 Таблица 2.1 Алгоритм Сложность Применимость Дейкстра О(Е tog V) Графы без отрицательных ребер, быстро на практике, находит расстояния до всех вершин Беллман-Феод O(V ‘ Е) Работает с отрицательными весами, выявляет отрицательные циклы, используется редко из-за медлительности А’ О(Е log V) worst, быстрее в среднем Поиск от точки до точки с эвристикой; требуетея допустимая оценка, эффективен для пространст- венных задач (навигация, игры) Существуют и другие алгоритмы кратчайших путей: .для ориентированных ациклич- ных графов (DAG)— решение за O(VtE) динамикой, а.поритм Флойда-У оршслла для всех пар (O(VA3)), алгоритм Джонсона для всех пар на разреженных графах, двунаправленный поиск и так далее. Потоки □ сетях В предыдущем разделе мы изучали графы как способ представления связей между объектами: искали кратчайшие пути, строили остовные деревья, обходили верши- ны. Но графы умеют моделировать не только связи, но и потоки — движение чего- либо от источника к получателю. Представьте водопроводную сеть, где по трубам течет вода, или логистическую компанию, развозящую посылки по складам. Сколько воды можно прокачать? Сколько грузов доставить? Ответы на эти вопро- сы дает теория потоков в сетях. Сети и потоки Транспортная сеть — это ориентированный граф, в котором выделены две особые вершины: исток (source, s) и сток (sink, t). Каждому ребру приписана неотрица- тельная пропускная способность (capacity) — максимальное количество «чего-то», что можно пропустить но этому ребру за единицу времени.
Поток — это функция, которая каждому ребру назначает величину «протекающе- го» по нему ресурса. При этом должны выполняться два условия: ♦ Ограничение пропускной способности поток по ребру не превышает его capacity. ♦ Сохранение потока: для каждой вершины, кроме истока и стока, сумма входя- щего потока равна сумме исходящею. Иными словами, ничего не теряется и не возникает из ниоткуда — сколько втекло, столько и вытекло. Величина потока — это суммарный поток, выходящий из истока (или, что то же самое, входящий в сток). Задача о максимальном потоке: найти поток наибольшей величины Пример. Артем управляет сетью складов (рис. 2.2). Товары производятся на фаб рике (исток) и должны попасть в главный мага зин (сток). Между точками — дороги с ограниченной пропускной способностью (только сколько-то грузовиков в день могут проехать). Артем хочет понять: какое максимальное количество товаров можно доставить ежедневно? Интуитивно понятно, что «узкие места?) ограничивают общий поток. Но как найти оптимальное распределение? Алгоритм Форда-Фалкерсона В 1956 году Лестер Форд и Делберт Фалкерсон предложили интересный метод, ос- нованный на простой идее- пока можно улучшить — улучшай. Основные концепции: ♦ Остаточная сеть (residual network) — граф, показывающий, сколько еще можно «дослать» по каждому направлению. Если по ребру (u, v) с пропускной способ- ностью с течет поток f, то: • остаточная прозсускная способность в направлении u—»v равна с - f (можно добавить еще столько); • в обратном направлении v—>u появляется «виртуальное» ребро с пропускной способностью f (можно «отменить» уже посланный поток).
♦ Увеличивающий путь (augmenting path) — путь из истока в сток в остаточной сети, по которому можно пропустить дополнительный поток. Минимальная ос- таточная пропускная способность на этом пути определяет, сколько потока можно добавить Сам алгоритм: 1. Инициализировать поток f = 0 по всем ребрам. 2. Пока существует увеличивающий путь Р из s в t в остаточной сети: 3. Найти минимальную остаточную пропускную способность 5 на пути Р. 4 Увеличить поток на 6 вдоль Р (и уменьшить обратные ребра на 8). 5. 3. Вернуть f как максимальный поток. Обратные ребра — это механизм «передумать?>. Если мы отправили 5 единиц по ребру А—»В. а потом нашли лучший маршрут через А—>С—>В. обратное ребро по- зволяет перенаправить часть потока Реализация на Go приведена в листинге 2.19. Представим граф Артема матрицей пропускных способностей. Для поиска увеличивающего пути используем BFS (это дает вариант Эдмондса-Карпа с гарантированной сложностью). — -Ж . ... _ tunc maxFlcwBFS(capacity [] [lint, s, t int) int { n := len(capacity) 11 residual хранит остаточные пропускные способности residual .= make([][]int, n) for i := range resiaual { residual[i] = make('[]int, n) copy(resiaual[i], capacity!!]) } parent .= make([]int, n) // для восстановления пути maxFlow := О // BFS для поиска увеличивавшего пути bfs := func () bool ( for i := range parent ( parent[i] = -1 } parent[s] = s queue := []int{s) for len(queue) > 0 { u := queue[0] queue = queue[1:] for v := 0; v < n; v++ { if parent [v] == -1 S.& residual [u] [v] > 0 {
parent[v] = u if v == t { return true } queue ₽ append(queue, v) ) } } return false } for bfs () { Il Находим минимальную пропускную способность на пути pathFlow := math.MaxInt for v := t; v != s; v = parent[v] { u := parent[v] if residual[u][v] < pathFlow { pathFlow = residual[u][v] } } // Обновляем остаточную сеть for v := t; v != s; v = parent[v] { u := parent[v] residual[u][v] -= pathFlow residual(v][u] += pathFlow // обратнее ребро } maxFlow += pathFlow } return maxFlow } Базовый алгоритм Форда-Фалкерсона с произвольным поиском пути имеет непри- ятную особенность: если пропускные способности — целые числа, он завершится, но может потребовать О(Е • f*) итераций, где f* — величина максимального пото- ка. При иррациональных capacity алгоритм может вообще не сойтись! Вариант Эдмондса-Карпа (с BFS) гарантирует не более O( V • Е) птерапий поиска пути, каждая за 0(E). Итого: O(V • Е2). Для сети Артема с 5 вершинами и 7 ребрами это приемлемо. Но что если вершин тысячи? Алгоритм Диница В 1970 году советский математик Ефим Диниц (публикация под именем «Динниц») предложил значительно более быстрый алгоритм. Идея: вместо того, чтобы искать один увеличивающий путь за раз, будем находить сразу много путей одинаковой длины.
Основные концепции: ♦ Слоистая сеть (layered network) — подграф остаточной сети, построенный с помощью BFS из истока. Каждой вершине присваивается уровень (расстояние от истока). В слоистую сеть включаются только ребра, ведущие с уровня i на уро- вень iH. Это отсекает «петляющие» пути и оставляет только кратчайшие. ♦ Блокирующий поток — поток в слоистой сети, после добавления которого не остается ни одного пути из истока в сток (каждый путь «заблокирован» хотя бы одним насыщенным ребром). Алгоритм: 1. Построить слоистую сеть с помощью BFS. 2. Если сток недостижим — конец, вернуть накопленный поток. 3. Найти блокирующий поток в слоистой сети (DFS с оптимизациями) 4. Добавить блокирующий поток к общему, обновить остаточную сеть. 5. Перейти к шагу 1. Главная оптимизация при поиске блокирующего потока —удаление мертвых кон- цов. Если DFS зашел в тупик (вершина не ведет к стоку), эту вершину можно ис- ключить из дальнейшего рассмотрения в текущей фазе. Реализуем этот алгоритм на Go (листинг 2.20). Лктмвг220 jji|| jfc J func dinic(capacity [][]int, s, t int) int { n := len(capacity) residual := make( [] []int, n) for i := range residual { residualfi] = шаке ([] int, n) copy(residual[i], capacity[i]) ) level := make([]int, n) // BFS для построения слоистой сети bfs := func () bool { for i := range level ( level[i] = -1 } level[s] = 0 queue := []int{s} for len(queue) > 0 { u := queue[0] queue = queue[1:] for v := 0; v < n; v++ {
if level[v] == -1 && residual[u][v] > 0 { level[v] = level[u] + 1 queue = append(queue, v) } } } return level[t] != -1 } // DFS для поиска блокирующего потока var dfs fur.clu, pushed int) int iter := make([]int, n) // указатель на следующее II ребре для просмотра dfs = func(u, pushed int) int { if u == t || pushed == 0 { return pushed } for ; iter[u] < n; iter[u]++ { v := iter[u] if level[v] != level[u]+l || residual[u][v] == 0 { continue ) // Пробуем протолкнуть поток flow := dfs(v, min(pushed, residual[u][v])) if flow > C { residual[u][v] -= flow residual[v][u] += flow return flew ) } return 0 } maxFlow := 0 for bfs() { for i := range iter { iter[i] = 0 } fcr { pushed := dfs(s, math.MaxInt) if pushed == 0 { break } maxFlow += pushed } 1 return maxFlow
Массив iter — основная оптимизация. Он запоминает, какие ребра из вершины уже просмотрены в текущей фазе Если ребро не привело к стоку, нет смысла пробовать его снова до перестроения слоистой сети. Каждая фаза (построение слоистой сети + блокирующий поток) увеличивает рас- стояние от истока до стока минимум на 1. Поскольку расстояние не может превы- сить V-1, фаз не более O(V). Поиск блокирующего потока с оптимизацией iter занимает O(V • Е) — каждое ребро рассматривается не более V раз суммарно по всем DFS в одной фазе. Итого. O(V2 • Е) — существенно лучше Эдмондса-Карпа на плотных графах. Для unit-capacity сетей (все пропускные способности равны 1) Диниц работает еще быстрее: О(Е • Vv). Применения Задачи о максимальном потоке возникают в удивительно разных областях: ♦ Логистика и транспорт. Сколько грузов можно перевезти из пункта А в пункт Б при ограниченной пропускной способности дорог9 Это основное применение. ♦ Сеги передачи данных. Какова максимальная пропускная способность между двумя узлами Интернета? Ребра — каналы связи, пропускная способность — bandwidth. ♦ Максимальное паросочетание. Классическая задача: есть п соискателей и m вакансий, каждый соискатель подходит на некоторые позиции. Как нанять мак- симум людей? Строим двудольный граф, добавляем исток (соединенный со все- ми соискателями) и сток (соединенный со всеми вакансиями), пропускные спо- собности = 1. Максимальный поток = максимальное паросочетание. ♦ Минимальный разрез. Теорема Форда-Фалкерсона утверждает: максимальный поток равен минимальному разрезу (минимальной сумме пропускных способно- стей ребер, удаление которых отделяет исток от стока). Это пспользуелся в сег- ментации изображений, кластеризации и даже в теории надежности сетей. ♦ Задача о назначениях. Распределить задачи между исполните, гями с учетом их способностей и ограничений — снова сводится к потокам. Теорема о максимальном потоке и минимальном разрезе Эта фундаментальная теорема связывает два, казалось бы, разных понят ия: Величина максимального потока из s a t равна пропускной способности минимального разреза, отделяющего s от t
Поток ограничен «узким горлышком» сети. Минимальный разрез — это и есть са- мое узкое место. Алгоритм Форда-Фалкерсона неявно находит этот разрез: после завершения вершины, достижимые из истока в остаточной сети, образуют одну часть разреза, остальные — другую. Потоковые алгоритмы — хороший инструмент, превращающий сложные комбина- торные задачи в вычислительно решаемые проблемы. Форд-Фалкерсон дал нам ин- туитивно понятный метод, Динин — сделал его практически применимым для больших графов
- Глава 3- Распределенные алгоритмы До сих пор мы молчаливо предполагали, что наши программы работают на одном компьютере. Один процессор, одна память, один диск Алгоритмы сортировки пе- ребирают элементы массива, лежащего «здесь и сейчас»?. Но мир изменился. Современные системы — это не один сервер, а тысячи машин, разбросанных по дата-центрам на разных континентах. Прежде чем погружаться в алгоритмы, давайте разберемся, почему мы вообще ус- ложняем себе жизнь. Почему бы не купить один очень мощный сервер? ♦ Масштабируемость. Представьте, что £ва запустила стартап— социальную сеть для пршраммистов. Первые месяцы все рабозаез на одном сервере: база данных, веб-приложение, хранилище файлов. Пользователей сотня, нагрузка минимальная. Но стартап «выстрелил»». Через год пользователей миллион Сервер задыхается: процессор загружен на 100%, память заканчивается, диск не успевает отвечать на запросы Что делать? ♦ Вертикальное масштабирование. Купить сервер помощнее. Больше ядер, больше памяти, быстрее диски. Это ра- ботает до определенного предела. Самый мощный сервер в мире все равно име- ет ограничения. И стоит он непропорционально дорого; сервер вдвое мощнее обычно стоит не вдвое, а ачезверо дороже. ♦ Горизонтальное масштабирование. Добавить больше серверов. Вместо одной супермашины— десять обычных. Вместо десяти— сто. Теоретически можно масштабироваться бесконечно. Практически — возникают новые проблемы, о которых и пойдет речь. ♦ Отказоустойчивость. Любой компьютер может сломаться. Диск выйдет из строя, память даст сбой, блок питания сгорит. Для домашнего компьютера это неприятность. Для банка, обрабатывающего транзакции. — целая катастрофа. Если система работает на одном сервере, его поломка означает полную недос- тупность. Пользователи видят ошибку, данные могуг быть потеряны, бизнес не- сет убытки. По моей статистике, средний сервер выходит из сгроя раз в 3-5 лет.
Распределенные системы позволяют пережить отказы отдельных компонентов. Если данные реплицированы на три сервера и один упал, два других продолжа- ют работать. Пользователь даже не заметит проблемы. ♦ Географическая распределенное!ь. Скорость света — фундаментальное ограничение. Сигнал от Москвы до Нью- Норка идет минимум 30 миллисекунд (по прямой через Атлантику). В реально- сти, с учетом маршрутизации— 80-100 мс. Д1я интерактивного приложения это заметная задержка. Если все серверы находятся в Москве, пользователь из Токио будет ждать ответа вдвое дольше, чем москвич. Решение — разместить серверы ближе к пользова- телям: в Европе, Азии, Америке. Но тогда нужно как-то синхронизировать дан- ные между ними. ♦ Специализация. Иногда разные части системы имеют принципиально разные требования. База данных нуждается в быстрых дисках и большом объеме памяти. Сервис машин- ного обучения — в мощных GPU. Веб-сервер — в большом количестве ядер для параллельной обработки запросов. Разделение системы па специализированные компоненты, работающие на опти- мальном для них оборудовании, — еще одна причина распределенности. Что делает распределенные системы сложными? Казалось бы, добавить серверов — не rocket science. Но как только данные пере- стаю! помещаться на одной машине, возникает целый класс проблем, которых про- сто нс существует в централизованных системах. На одном компьютере есть системные часы. Если событие А произошло в 12:00:00 001, а событие В — в 12:00:00 002, мы точно знаем, что А было раньше. Теперь представьте два сервера в разных дата-пенграх. У каждого свои часы. Эти часы никогда не показывают абсолютно одинаковое время — всегда есть расхож- дение, пусть даже в микросекунды. Хуже того, часы «дрейфуют»: из-за температу- ры, напряжения питания и других факторов они постепенно расходятся. Протоколы синхронизации времени помогают, но не решают проблему полностью. Даже после синхронизации расхождение может составлять десятки миллисекунд. А ь распределенной системе за миллисекунду могут произойти тысячи операций. Николай и Вася одновременно редактируют один документ. Николай на сервере в Москве меняет заголовок в 12:00:00.100 по московским часам. Вася на сервере в Берлине меняет тот же заголовок в 12:00:00.050 по берлинским часам Чье измене- ние «последнее»? Если часы рассинхронизированы на 100 мс, ответ неочевиден. Помимо этого, сеть может быть ненадежной.
Компьютеры общаются через сеть. Сеть — штука капризная: ♦ Потеря пакетов. Сообщение отправлено, но не дошло. Отправитель не знает об этом, получатель не знает, что что-то должно было прийти. ♦ Задержки. Сообщение дошло, но через секунду вместо миллисекунды. Получатель уже ре- шил, что отправитель мертв. ♦ Переупорядочивание. Сообщение А отправлено раньше В, но В пришло первым. ♦ Дублирование. Сообщение доставлено дважды (из-за ретрансмиссии на уровне TCP). ♦ Разделение сети (network partition). Часть серверов потеряла связь с другой частью. Обе части работают, но не витят друг друга. При разделении сети нельзя отличить ситуацию «сервер упал» от «сервер жив, но недоступен». Это фундаментальная неразличимость, с которой приходится жить. ♦ Возникают и частичные отказы. В централизованной системе отказ обычно полный: либо всё работает, либо ни- чего. В распределенной отказывает часть системы. Три сервера из десяти недос- тупны, но семь продолжают работать. Запрос обработан на одном сервере, но не реплицирован на другие. Транзакция выполнена частично. Частичные отказы создают несогласованные состояния. Пользователь совершил покупку, деньги списались, но заказ не создался. Или наоборот — заказ создался дважды. Обработка таких ситуаций— одна из главных сложностей распреде- ленных систем. ♦ Стоит упомянуть и о параллелизме. Когда тысячи клиентов одновременно обращаются к системе, возникают со- стояния гонки. Два запроса читают одно значение, оба решают его изменить, оба записывают — один перезаписывает результат другого. На одной машине эту проблему решают блокировки. В распределенной системе блокировки работают иначе: нужно договориться между серверами, кто владеет блокировкой. А что если сервер, владеющий блокировкой, упал? Или стал не- доступен из-за сети? САР-теорема: фундаментальный компромисс В 2000 году' профессор Эрик Брюер из Беркли сформулировал гипотезу, которая через два года была формально доказана и стала известна как САР-теорема. Она
описывает фундаментальное ограничение, с которым сталкивается любая распре- деленная система. Три свойства САР — аббревиатура трех свойств: ♦ Consistency (согласованность) — все узлы видят одни и те же данные в одно и то же время. Если клиент записал значение Х=5, любой последующий запрос на чтение (на любом узле) вернет 5. Это строгая согласованность (strong consistency), также называемая линеаризуемостъю (linearizability). Строгая согласованность означает, что распределенная система ведет себя как единый сервер. Клиенты могут общаться с любым узлом и получать одинаковые ответы, как если бы данные хранились в одном месте. ♦ Availability (доступность) — каждый запрос получает ответ (успех или неуда- ча), без гарантии, что ответ содержит самые свежие данные. Система не «зави- сает». не возврашает timeout, а отвечает за разумное время. Доступность здесь означает, что работающий узел всегда отвечает Если узел упал — он не обязан отвечать Но если жив и получил запрос — должен ответить. ♦ Partition tolerance (устойчивость к разделению) — система продолжает рабо- тать, несмотря на потерю или задержку произвольного числа сообщений между узлами. Иными словами, система выдерживает разделение сети. Теорема С АР-теорема утверждает. В распределенной системе невозможно одновременно обеспечить все три свойства. Можно выбрать не более двух из трех. Это математически доказанное ограничение, Никакой алгоритм, никакое оборудо- вание, никакой гений инженерной мысли не обойдет САР-теорсму. Почему нельзя всё сразу? Рассмотрим на примере. У нас два сервера: А и В — хранящие одну переменную X. Изначально Х=0. ♦ Сценарий 1: сеть работает нормально. 1. Клиент записывает Х=1 на сервер А. 2. Сервер А отправляет обновление серверу В. 3 Сервер В обновляет свою копию: Х=1. 4. Любой последующий запрос (на А или В) вернет Х=1. 5 Все три свойства выполняются. Прекрасно!
♦ Сценарий 2: сеть разделилась (partition). 1. Связь между А и В потеряна. 2. Клиент записывает Х=1 на сервер А. 3. Сервер А не может передать обновление серверу В. 4. Другой клиент читает X с сервера В. 5. Что должна вернуть система? • Вариант 1: Вернуть Х=0 (старое значение). Система доступна (В отве- тил), устойчива к разделению (работает несмотря на partition), но несогла- сована (А и В показывают разные значения). Это система типа АР. • Вариант' 2: Не отвечать, пока связь не восстановится. Система согласо- вана (когда ответит, вернет правильное значение), устойчива к разделе- нию, но недоступна (В не отвечает на запросы). Это система типа СР. • Вариант 3: ??? Нет третьего варианта Либо ответить (возможно, уста- ревшими данными), либо не ответить. Tertium non dalur. А что с СА? Теоретически можно построить систему СА — согласованную и доступную, но не- устойчивую к разделению На практике это означает: «когда сеть работает, всё хо- рошо». Проблема в том, что сеть будет давать сбои. Эю не вопрос «если», а вопрос «ко- гда». Провода перегрызут, роутеры перезагрузятся, дата-центр затопит. Система, неустойчивая к разделению. — это система, которая периодически полностью отка- зывает. Поэтому в реальных распределенных системах выбор всегда между СР и АР. Partition tolerance — обязательное требование. Вопрос: чем жертвовать при разде- лении — согласованностью или доступностью? Примеры систем ♦ СР-системы предпочитают согласованность; • Традиционные реляционные базы данных (PostgrcSQL, MySQL) в режиме синхронной репликации. Запись подтверждается только после репликации на все узлы. При разделении сети запись блокируется. • ZooKeeper, eted, Consul — системы координации, использующие алгоритмы консенсуса (Raft, ZAB). При потере кворума система отказывается принимать запросы • HBase. MongoDB (в определенных конфигурациях) — при разделении выби- рают согласованность.
♦ АР-системы предпочитают доступность: • Cassandra, DynamoDB — отвечают на запросы даже при разделении, при- нимая риск временной несогласованности. • CouchDB— позволяет редактировать документы офлайн с последующим слиянием изменений. • DNS — возвращает закешированные записи, даже если они устарели. Критика и уточнения САР-георема часто понимается неправильно. Несколько уточнений: ♦ Это не «выбери два из трех». Система не обязана постоянно отказываться от одного свойства. Большинство времени (когда сети работает) можно иметь все три Выбор происходит во время разделения ♦ Согласованность — это спектр. С АР говорит о строгой согласованности (linearizabiliry). Существуют более слабые модели (eventual consistency, causal consistency), которые дают другие компромиссы. ♦ Доступность — тоже спектр. В теореме доступность означает «100% запросов получают ответ». На практике можно говорить о 99,9% доступности, принимая редкие отказы. ♦ Разделение— не бинарное. Сеть может «подтормаживать»: пакеты не теряют- ся, но идут медленно Формально это не partition, но система ведет себя похоже. Даниэль Абади предложил расширение PACELC: в нормальном режиме (без partition) выбираем между Latency и Consistency, при разделении — между Availability и Consistency. Это лучше отражает реальные компромиссы. Модели согласованности САР-теорема бинарна: либо строгая согласованность, либо нет. Но на практике су- ществует целый спектр моделей согласованности, каждая со своими гарантиями и ценой. Строгая согласованность (Strong Consistency I Linearizability) Самая интуитивная модель: система ведет себя так, как будто есть единственная копия данных. Все операции упорядочены, и каждая операция чтения возвращает результат последней записи. Формально: существует такой глобальный порядок всех операций, что: ♦ он согласуется с реальным временем (если операция А завершилась до начала В, А предшествует В); ♦ каждое чтение возвращает значение последней предшествующей записи.
Это сильная гарантия, но дорогая. Для ее обеспечения нужно, чтобы узлы коорди- нировались перед каждой операцией. При географической распределенности это добавляет сотни миллисекунд к каждому запросу. Пример: банковская система. Нельзя допустить, чтобы два банкомата одновремен- но выдали деньги со счета, на котором недостаточно средств. Строгая согласован- ность гарантирует, что после снятия денег в Москве банкомат в Петербурге сразу увидит обновленный баланс. Последовательная согласованность (Sequential Consistency) Чуть слабее линеаризусмости. Все операции упорядочены, но этот порядок не обя- зан соответствовать реальному времени. Важно лишь, чтобы все клиенты видели одинаковый порядок. Разница тонкая, но важная В линеаризуемой системе, если Артем завершил запись до того, как Ева начала чтение, Ева обязана увидеть записанное. В последовательно согласованной системе — не обязана, если Артем и Ева на разных узлах и не син- хронизировались явно. Причинная согласованность (Causal Consistency) Гарантирует, что причинно-связанные операции видны всем в одном порядке. Если операция В зависит от результата операции А (например, В прочитала значение, записанное А), то все узлы увидят А до В, Операции, не связанные причинно, могут быть видны в разном порядке на разных узлах Пример: социальная сеть. Ева публикует пост, Артем комментирует. Комментарий причинно зависит от поста. Все пользователи должны видеть сначала пост, потом комментарий. Но если Борис и Даша одновременно лайкаю! разные посты— эти операции независимы, и разные пользователи могут видеть их в разном порядке. Причинная согласованность— хороший компромисс: достаточно сильна для большинства приложений, но позволяет высокую производительность. Read Your Writes (прочитайте ваши записи) Гарантия: после записи последующие чтения того же клиента вернут записанное значение (или более свежее). Звучит очевидно, но без специальных мер это не гарантируется. Клиент может за- писать данные на узел А, а следующий запрос на чтение попадет на узел В. кото- рый еще не получил обновление Решения: ♦ Sticky sessions: привязать клиента к одному узлу. ♦ Версионирование: клиент помнит версию последней записи и гребует не менее свежую при чтении. ♦ Синхронная репликация: запись подтверждается после репликации на все узлы.
Eventual Consistency (согласованность в конечном счете) Самая слабая модель, но и самая доступная. Гарантия: Если новые обновления прекратятся, все реплики в конечном счете придут к одинаковому состоянию. Обратите внимание: нет гарантий о времени, «В конечном счете» может означать миллисекунды или часы Нет гарантий о порядке: разные клиенты могут видеть разные промежуточные состояния. Это может звучать бесполезно, но на практике eventual consistency достаточна для удивительно многих сценариев. ♦ Счетчики лайков. Пользователь лайкнул пост. На его экране счетчик увели- чился другой пользователь видит старое значение еще несколько секунд— это нормально, скоро обновится ♦ Профили пользователей. Ева обновила аватарку Артем видит старую еще ми- нуту. Неприятно, но не критично. ♦ Каталог товаров. Цена изменилась, но часть пользователей видит старую. При добавлении в корзину пересчитается. ♦ DNS. Изменение записи распространяется часами Это нормально для системы с миллиардами кешей. Eventual Consistency Рассмотрим eventual consistency подробнее— это основа большинства высокона- груженных систем. Если реплики могут принимать записи независимо, как они приходят к единому состоянию 7 Существует несколько механизмов: ♦ Anti-entropy (антиэнтропия). Узлы периодически сравнивают свои данные и устраняют расхождения. Для эффективного сравнения используются специальные структуры данных: • Merkle trees — хеш-дере вья, позволяющие быстро найти различающиеся участки данных. Сравнивая хеши корней, узлы определяют, есть ли расхож- дения. Затем спускаются по дереву, сужая поиск (листинг 3.1). type MerkleNode struct ( Hash Mbyte Left ’MerkleNode Right Data ’’MerkleNode [,byte // только для листьев
func (n ‘MerkleNoae) ComputeHash() ( if n.Left = nil 4S n.Fight — nil { // Лист; хеш от данных h := sha256.Sum256(n.Data) n.Hash = h[:] ) else { // Внутренний узел: хеш от конкатенации хешей детей combined := append(n.Left.Hash, n.Right.Hash...) h := sha256.Sum2f6(combined) n.Hash = h[:] ) } При синхронизации узлы начинают с корней. Если хеши совпадают — дан- ные идентичны. Если нет— спускаются к детям, пока не найдут конкретные различающиеся записи. • Read Repair— при чтении система обращается к нескольким репликам и сравнивает ответы Если ответы различаются, выбирается «правильный^ (обычно самый свежий), и устаревшие реплики обновляются (листинг 3.2). func readwithRepair(кеу string, replicas [JNode) (Value, error) ( responses := make([]Response, 0, len(replicas)) // Читаем co всех реплик параллельно for , г := range replicas ( resp, err := r.Read(key) if err = nil { responses - append(responses, resp) } ) // Находим самое свежее значение (по timescamp или version) var newest Response for _, resp := range responses { if resp.Version > newest.Version { newest = resp } } // Асинхронно обновляем устаревшие реплики go func () ( for _, г := range replicas { r.Repair(key, newest.Value, newest.Version) } } 0 return newest.Value, nil
4 Hinted Handoff. Если целевой узел недоступен, запись временно сохраняется на другом узле с «подсказкой» (hint), кому она предназначена. Когда целевой узел возвращается, данные передаются ему. Разрешение конфликтен При eventual consistency разные реплики могут получить разные записи одного ключа. Когда они синхронизируются, возникает конфликт: какое значение «пра- вильное»? ♦ Last-Writer-Wins (LWW). Простейший подход — каждая запись имеет timestamp, побеждает запись с наи- большим umestamp (листинг 3.3). Листинг 3.3 type LWWValue struct { Data interfaced Timestamp int64 } func (v ‘LWWValue) Merge(other LWWValue) LWWValue { if other.Timestamp > v.Timestamp { return other } return *v } Просто, но проблематично: • зависимость от синхронизации часов — если часы рассинхронизированы, «позднее» по часам не значит «позднее» в реальности; • потеря данных — если Артем и Ева одновременно записали разные значения, одно будет потеряно. ♦ Vector Clocks (векторные часы). Более интересный подход— вместо одного timestamp поддерживается вектор счетчиков, по одному на каждый узел (листинг 3.4). Листинг 3.4 type VectorClock map[string]int // nodelD -> logical timestamp func (vc VectorClock) Increment(nodelD string) { vc[nodeID]++
func (vc VectorClock) Merge(other VectcrClock) VectorClock { result := make(VectcrClock) for k, v : = range vc { result[k] = v } for k, v := range other { if v > result Lk] { result[k] = v } } return result } // Проверяет, предшествует ли vc другом-/ вектору func (vc VectorClock) HappensBefcre(other VectcrClock) bool { atLeastOneI.ess := false for k, v := range vc { otherV := other[k] if v > otherV { return false II не мотет предшествовать, // если хоть един компонент больше ) if v < otherV { atLeastCneLess = true } } // Проверяем ключи, которые есть в other, но нет в vc for k, v := range other { if _, exists := vc[k]; (exists && v > 0 { atLeastCneLess = true ) } return atLeastCneLess ) // Проверяем, конфликтуют ли версии (ни одна не предшествует другой) func (vc VectorClock) Conflicts(other VectorClock) bool { return (vc.HappensBefore(other) S& (other.HappensBefcre(vc) } Векторные часы позволяют определить причинно-следственные связи: • если vcl < vc2, то vcl причинно предшествует vc2; • если ни vcl < vc2, ни vc2 < vcl, версии конкурируют это настоящий кон- фликт.
При конфликте система может; • сохранить обе версии и вернуть клиенту для ручною разрешения ('Amazon DxnamoDB гак делает для определенных случаев); • автоматически слить, если семантика данных позволяет. ♦ CRDTs (Conflict-free Replicated Data Types). Математически доказанный способ избежать конфликтов— использовать структуры данных., которые по определению всегда мохут быть слиты однозначно. Пример. G-Counter (grow-only counter) — счетчик, который можно только уве- личивать (листинг 3.5). type GCounter struct { Counts map[string]int II nodelD -> локальное значение } func NewGCounter () ’GCounter { return SGCounter{Counts make(mapfstring]int)} } func (c *CCounter) Increment(nodeID string) { c.Counts[nodelD]++ ) func (c ’GCounter) Valued int { sure := 0 for v := range c.Counts { sum += v } return sum func (c ’GCounter) Merge(other ’GCounter) { for k, v := range other.Counts { if v > c.CountsLk] I c.Counts[k] = v } ) Каждый узел увеличивает только свой счетчик. При слиянии берется максимум по каждому узлу. Это коммутативная, ассоциативная и идемпотентная опера- ция — слияния можно применять в любом порядке, любое число раз, результат будет одинаковым. На основе G-Counter можно построить PN-Counter (поддерживающий и увели- чение, и уменьшение). LWW-Register, OR-Set и другие CRDT.
Кворумы: настраиваемая согласованность Многие распределенные хранилища позволяю! настраивать баланс между согласо- ванностью и доступностью через механизм кворумов (листинг 3.6). Пусть данные реплицируются на N узлов Определим: ♦ W — число узлов, которые должны подтвердить запись. ♦ R — число узлов, с которых нужно прочитать. Правило: ♦ Если W + R > N, чтение гарантированно пересечется с записью — система строго согласована ♦ Если W + R < N, возможно чтение устаревших данных — eventual consistency. Примеры конфигураций (при N=3) приведены в табл. 3.1. Таблица 3.1 W R W+R Характеристика 3 1 4 Медленная запись, быстрое чтение, согласованно 1 3 4 Быстрая запись, медленное чтение, согласование 2 2 4 Сбалансировано, согласованно 1 1 2 Максимальная доступность, eventual consistency суре QuorumConfig struct { N int // общее число реплик W int II кворум записи R int // кворум чтения } func (q QuorumConfig) IsStronglyConsistent() bool { return q.W + q.R > q.N } func (q QuorumConfig) ToleratesWriteEailures() int { return q.N - q.W // сколько узлов может упасть при записи } func (q QuorumCcnfig) ToleratesReadfailures () int { return q.N - q.R // сколько узлов может упасть при чтении } Кворумы достаточно мощный инструмент: одна система может предоставлять раз- ные уровни согласованности для разных операций. Критичные данные пишутся с W = N, некритичные — с W = 1.
Окно несогласованности При eventual consistency существует окно несогласованности — период между за- писью и моментом, когда все реплики получат обновление. Размер окна зависит от. ♦ Сетевых задержек между узлами. ♦ Стратегии репликации (синхронная, асинхронная, lazy). ♦ Нагрузки на систему. ♦ Размера данных для репликации. В хорошо настроенной системе окно обычно составляет миллисекунды или секун- ды. Но при проблемах с сетью или перегрузке оно может расти. Некоторые системы предоставляют SLA на окно несогласованности. Amazon S3, например, гарантирует «read-after-write consistency» для новых объектов, сразу по- сле успешной записи объект доступен для чтения. Но для перезаписи существую- щих объектов — eventual consistency. Практические паттерны Теория — это прекрасно, но как применять эти знания на практике? Saga — для распределенных транзакций В монолитной системе с одной базой данных транзакции обеспечивают ACID- гарантии: либо все изменения применяются, либо никакие В распределенной сис- теме с несколькими базами это не работает. Saga — последовательность локальных транзакций, каждая из которых публикует событие или сообщение, запускающее следующую транзакцию. Если какой-то шаг не удался, выполняются компенсирующие транзакции (rollback). Пример. Бронирование путешествия: авиабилет + отель + машина (листинг 3.7). Листинг 3.7 гуре SagaStep struct { Name string Execute funcfctx context.Context) error Compensate func(ctx context.Context) error type Saga struct { Steps []SagaStep Completed [Jint // индексы выполненных шагов
func (s *Saga) Run(ctx context.Context) error { for i, step := range s.Steps { err := step.Execute (ctx) if err != nil { /I Откатываем выполненные шаги в обратном порядке for j := len(s.Completed) - 1; j >= 0; j— { i dx := s.Completed[j] _ - s.Steps[idx].Compensate (ctx) // компенсация // best-effort } return fmt.Errorf("step %s failed: %w", step.Name, err) } s.Completed = append(s.Completed, i) } return nil ) // Использование func bookTrip(ctx context.Context, trip TripRequest) error { saga := SSagal Steps: []SagaStep{ { Name: "book_flight", Execute: func (ctx context.Cor.text) error { return flightService.Book(trip.Flight) }, Compensate: func(ctx context.Context) error { return flightService.Cancel(trip.Flight) }, }, { Name: "book_hotel", Execute: func(ctx context.Context) error { return hotelservice.Book(trip.Hotel) }, Compensate: func(ctx context.Context) error { return notelService.Cancel(trip.Hotel) }, }, { N air.e: "book_car ", Execute: func(ctx context.Context) error { return carService.Book(trip.Car) }, Compensate: func(ctx context.Context) error { return carService.Cancel(trip.Car) }, }, }, } return saga.Run(ctx) } Единственное неудобство — промежуточные состояния видны другим транзакци- ям. Но для многих сценариев это приемлемо.
Outbox — для надежной публикации событий Частая задача — обновить базу данных и отправить сообщение в очередь. Пробле- ма — это две разные системы, атомарность не гарантирована (листинг 3.8). // НЕПРАВИЛЬНО: если краинемся между этими вызовами, // сообщение отправится, а база не обновится (или наоборот) tunc processOrder(order Order) error { err := db.OpdateOrcer(order) if err 1= nil { return err ) err = queue.Publish(OrderUpdatedEventfGrder: order)) return err } Outbox pattern; вместо отправки в очередь записываем событие в ту же базу дан- ных. в таблицу outbox. Отдельный процесс читает outbox и отправляет события в очередь (листинг 3.9). func processOrder(order Order) error ( return db.Transaction(func(tx *sql.Tx) errcr { // Обновляем заказ err := tx.Exec("UPDATE orders SET status=$l WHERE id=$2", order.Status, order.ID) if err != nil { return err // Записываем событие в outbox (в той ке транзакции!) eventData, _ := json.Marshal (0rderUpdatedEvent(0rder: order)) _, err = tx.Exec("INSERT INTO outbox (event_type, payioad) VALUES (SI, $2)", "ordei_updated", eventData) return err }) Il Отдельный процесс (или горутина) func outboxPoller(db *sql.DB, queue MessageQueue) { for { events, _ := db.Query("SELECT id, event_type, payload FROM outbox LIMIT 100") for events.Next() { var id int
var eventType string var payload []byte events.Scan(kid, ieventType, ipayload) // Отправляем в очередь if err ;= queue.Publish(eventType, payload); err = nil { II Удаляем из outbox только после успешней отправки db.Exec("DELETE FROM outbox WHERE id=$l", id) } ) time.Sleep(100 * time.Millisecond) } } Теперь обновление базы и создание события атомарны (одна транзакция) Если poller упадет, он возобновит работу с того места, где остановился,— события не потеряются. Idempotency key — для идемпотентных операций В распределенной системе запрос может выполниться, но отвез потеряется. Клиент не знает, успешно ли завершилась операция, и повторяет запрос. Если операция неидемпотентна (например, списание денег), это катастрофа. Решение: каждый запрос содержит уникальный idempotency key. Сервер запомина- ет результаты операций и при повторном запросе возвращает сохраненный резуль- тат (листинг 3.10). type IdempotencyStore interface ( Get(key string) (result [Jbyte, found bocl, err errer) Set(key string, result []byte, ttl time.Duration) error func idempotentHandler(store IdempotencyStore, handler func(req Request) Response) http.HandlerFunc ( return func(w http.ResponseWrrter, r *http.Request) { idempotencyKey := r.Header.Get("Idempotency-Key") if idempotencyKey == "" { http.Error(w, "Idempotency-Key required", http.StatusBadRequest) return ) II Проверяем, выполнялся ли уже этот запрос if cached, found, _ := stere. Get(idempotencyKey); found ( w.Write(cached)
return } // Выполняем операцию var req Request json.NewDecoder(г.Body).Decode(4reqj response := handler(req) // Сохраняем результат respcr.seBytes, _ := json.Marshal (response) store.Set(idempotencyKey, responseBytes, 24*time.Hour) w.Write(responseBytes) } } Клиент генерирует UL'ID для каждой логической операции и передает его во всех повторных попытках. Circuit breaker — для устойчивости Когда внешний сервис недоступен, нет смысла бомбардировать его запросами — это только усугубит проблему. Circuit breaker размыкает цепь после нескольких неудачных попыток (листинг 3.11). type Circuitsгеакег struct { mu sync.Mutex failures int successes int state string // "clcsed", "open", "half-open" lastFailureTime time.Time FailureThreshcld int SuccessThreshold int Timeout time.Duration } func (cb *CircuitBreaker) Call(fn fur.cO error) error { cb.mu.Lockf) // Проверяем состояние switch cb.state { case "cpen":
// Если тайм-аут прешел, пробуем полуоткрыть if time.Since (cb.lastFailureTime) > cb.Timeout { cb.state = "haif-epen" cb.successes = 0 } else { cb.mu.Unlock() return errors.New("circuit breaker is open") } } cb.mu.Unlock() // Выполняем вызов err := fn() cb.mu.LockO defer cb.mu.Unlock() if err != nil { cb.failuresr+ cb.lastFailureTime = time.Now() if cb.failures >= cb.EailureThreshcld { cb.state = "open" } return err } // Успех if cb.state == "half-cper." { cb.successes++ if cb.successes >= cb.SuccessThreshold { cb.state = "closed" cb.fa:lures = 0 } } else { cb.failures = 0 } return nil Состояния: ♦ Closed: нормальная работа. После N неудач переходим в Open. ♦ Open: все вызовы сразу возвращают ошибку. После таймаута переходим в Half- Open. ♦ Half-Open: пропускаем шраниченное число вызовов Если успешны — в Closed, если нет — обратно в Open.
♦ ♦ ♦ Распределенные системы— это отдельная вселенная со своими законами. САР- теорема— лишь вершина айсберга. Под ней скрываются алгоритмы консенсуса (Paxos, Raft, PBFT), протоколы репликации, механизмы обнаружения отказов, стра- тегии партиционированпя и многое другое. Главный урок этой главы: нет универсального решения. Каждая система— ком- промисс. Понимание САР и моделей согласованности позволяет осознанно выби- рать этот компромисс, а не натыкаться на грабли в продакшене В следующих главах мы рассмотрим алгоритмы консенсуса и распределенные транзакции.
- Глава 4- Алгоритмы консенсуса В предыдущей главе мы столкнулись с фундаментальной проблемой распределен- ных систем: как нескольким узлам договориться о чем-либо, если сеть ненадежна, а утлы могут падать? САР-теорема показала, что при разделении сети приходится выбирать между согласованностью и доступностью. Но как именно достичь согла- сованности, когда мы ее выбираем? Ответ— алгоритмы консенсуса. Это, пожалуй, самая интеллектуально сложная область распределенных систем. Здесь математика и теоремы встречаются с суро- вой реальностью сетевых сбоев Алгоритмы консенсуса лежат в основе баз данных (eted, CockroachDB. TiDB), блокчейнов (Bitcoin, Ethereum), координационных сер- висов (ZooKeeper, Consul) и множества других важных систем. Что такое консенсус и зачем он нужен? Консенсус — это процесс, в результате которого группа узлов приходит к единому решению, даже если часть из них выходит из строя Звучит просто? Давайте усложним Представьте пять серверов, хранящих реплики одной базы данных. Клиент хочет записать значение X = 42. Для согласованности все серверы должны либо принять эту запись, либо отклонить — и сделать это со- гласованно. Но ♦ Сообщения между серверами могут теряться или задерживаться произвольно долго. ♦ Любой сервер может упасть в любой момент— и потом восстановиться с уста- ревшим состоянием. ♦ Нет глобальных часов— серверы не могут договориться «примем решение в 12:00:00.000». ♦ Нет выделенного «главного» — любой сервер может инициировать операцию. Как в таких условиях гарантировать, что все серверы примут одинаковое решение? И что это решение не будет потеряно при сбоях?
Формальное определение Алгоритм консенсуса должен обеспечивать три фундаментальных свойства: ♦ Agreement (согласие). Все корректно работающие узлы принимают одно и го же решение. Не может быть ситуации, когда узел А решил «да», а узел В решил «нет» ♦ Validity (валидность). Принятое решение было предложено каким-то узлом Алгоритм не может «вы- думать» значение из ниоткуда — он выбирает из предложенных. ♦ Termination (завершаемость). Все корректно работающие узлы в конечном счете примут решение. Алгоритм не может «зависнуть» навечно. Первые два свойства относятся к безопасности (safety): система никогда нс делает ничего плохого. Третье — к живучести (liveness): система в конечном счете делает что-то полезное. Как мы увидим, эти свойства находятся в напряжении друг с другом. Практические применения Зачем нужен консенсус на практике? Рассмотрим несколько сценариев. ♦ Репликация состояния (State Machine Replication). Главное применение консенсуса. Идея элегантна если несколько машин начи- нают с одною состояния и применяют одни и ге же операции в одном и то.м же порядке, они придут к одному конечному состоянию. Консенсус используется именно для согласования этого порядка (рис. 4.1). Рис. 4.1
♦ Выбор лидера. В системах с одним мастером нужно договориться, кто этот мастер. Если мастер упал, надо выбрать нового — и все должны согласиться, кто это. Два мастера одновременно (split-brain) — катастрофа ♦ Распределенные блокировки. Чтобы гарантировать, что только один процесс владеет блокировкой в каждый момент времени, нужен консенсус о том, кто владелец. ♦ Атомарный broadcast. Доставка сообщений всем узлам в одном и том же порядке — эквивалентная консенсусу задача. Невозможность: почему это так сложно? Прежде чем изучать решения, давайте глубоко проникнемся вопросом: почему проблема настолько сложна. Это не вопрос инженерного гения — существуют ма- тематически докатанные О1раничения, Проблема двух генералов Классическая задача, иллюстрирующая фундаментальную сложность координации через ненадежный канал. Две армии, возглавляемые генералами Артемом и Евой, окружили вражеский город с двух сторон. Чтобы победить, они должны атаковать одновременно. Если атакует только одна армия — она будет разбита превосходящими силами противника. Генералы могут общаться только через гонцов, которые должны пересечь враже- скую территорию. Гонец может быть перехвачен — сообщение потеряно. Связь ненадежна, и это нельзя исправить. Задача: разработать протокол, гарантирующий, что оба генерала либо атакуют, ли- бо не атакуют. Почему эю невозможно? Допустим, Артем отравляет Еве сообщение: «Атакуем на рассвете». Рассмотрим цепочку рассуждений: 1. Ева получает сообщение. Но Артем не знает об этом — гонец мог быть перехва- чен на обратном пути. Артем не будет атаковать без подтверждения. 2. Ева отправляет подтверждение: «Принято, атакуем». Но теперь Ева не знает, дошло ли подтверждение. Если нс дошло, Артем не атакует. Ева не будет атако- вать без подтверждения подтверждения. 3. Артем получает подтверждение и отправляет «подтверждение подтверждения». По теперь Артем не знает...
И так до бесконечности. Каким бы конечным числом сообщений мы бы ни обменя- лись, последнее сооб?цение в цепочке всегда может быть потеряно. И отправитель последнего сообщения никогда не узнает, дошло ли оно. Он не может безопасно атаковать (рис. 4.2). Вывод: в системе с возможностью потери сообщений невозможно гарантированно достичь консенсуса о совместном действии Это не ограничение конкретного про- токола — это фундаментальная невозможность. FLP-невозможность В 1985 году Майкл Фишер, Нэнси Линч и Майкл Патерсон доказали еше более сильный и неожиданный результат, известный как теорема FLP В асинхронной системе, где хотя бы один процесс может отказать, не существует детер- минированного алгоритма консенсуса, который гарантированно завершается. Разберем это утверждение по частям. ♦ Асинхронная система — нет верхней границы на время доставки сообщений. Сообщение может идти миллисекунду или час — мы не знаем заранее. Важно: мы не можем отличить ситуацию «узел упал» от «узел просто очень медлен- ный». Любой таймаут может ошибиться. ♦ Один процесс может отказать — минимальное предположение о сбоях. Даже в самой надежной системе хотя бы один компьютер может сломаться. ♦ Дет ерминированный алгоритм — алгоритм без случайности. Следующий шаг полностью определяется текущим состоянием и полученными сообщениями.
♦ Г арап гированно завершается — алгоритм всегда приходит к решению за ко- нечное время. Теорема утверждает: при этих условиях консенсус невозможен. Не «сложен» — невозможен! Существует последовательность событий (порядок доставки сообще- ний. момент отказа), при которой любой детерминированный алгоритм будет от- кладывать решение вечно. Интуиция доказательства Представьте, что система находится в «критическом» состоянии — малейшее из- менение определяет исход (0 или 1). В таком состоянии алгоритм не может безо- насно завершиться, потому что: ♦ если он решит «0» без ожидания всех сообщений, может оказаться, что «опо- здавшее» сообщение несло информацию, меняющую решение на «1»; ♦ если он будет ждать все сообщения, он может ждагь вечно (сообщение от упав- шего узла никогда не придет). FLP показывает, что такие критические состояния неизбежны, и из них невозможно выбраться детерминированно. Как жить с невозможностью? FLP-невозможность не означает, что консенсус бесполезен Сна означает, что при- ходится о слаб зять требования'. ♦ Вероятност ное завершение. Вместо «всегда завершается» — «завершается с вероятностью 1». Рандомизиро- ванные алгоритмы (Веп-Ог) могут в критических ситуациях «подбрасывать мо- нетку». С вероятностью I система выйдет из тупика, хотя теоретически может «застрять» на сколь угодно долго. ♦ Детектор отказов. Предположим, что существует (несовершенный) механизм определения, упал ли узел. Даже если он иногда ошибается, этого достаточно для консенсуса. ♦ Частичная синхронность. Предположим, что обычно сообщения доставляются за ограниченное время, хо- тя бывают периоды асинхронности Это модель реальных сетей: они работают нормально, но иногда их «штормит». Практические алгоритмы, рассмотренные далее, работают именно в модели час- тичной синхронности. Они всегда безопасны (никогда не примут противоречивых решений), но могут временно застрять при неблагоприятных условиях. Когда сеть стабилизируется, алгоритм продолжит работу На практике это приемлемо: сеть обычно работает нормально, и алгоритмы завер- шаются быстро. В редких случаях нестабильности система «пережидает шторм» и восстанавливается.
Paxos: алгоритм, изменивший асе В 1989 году Лесли Лэмпорт разработал алгоритм, который он назвал Paxos— в чесгь вымышленного греческого острова, где древние законодатели должны были принимать решения, несмотря на то, что постоянно приходили и уходили. Академическое сообщество не оценило юмор. Статья была отклонена. Лэмпорт от- ложил ее на десять лет. Только в 1998 году, после переписывания в традиционном стиле, работа была опубликована. А в 2001 году вышла статья «Paxos Made Simple», начинающаяся с фразы: «Алгоритм Paxos, если изложить его простым английским языком, очень прост». Paxos имеет репутацию невероятно сложного для понимания алгоритма. Google потратил годы на его корректную реализацию в Chubby. Даже шутят, что есть только несколько человек в мире, которые действительно понимают Paxos, — и двое из них не согласны друг с другом. Тем не менее Paxos — фундамента тьный алгоритм. Понимание его идей необходи- мо для работы с любыми распределенными системами. Давайте разберемся. Paxos работает в следующих предположениях: ♦ Асинхронная сеть с возможными сбоями. Сообщения могут теряться, дублироваться, задерживаться. Но они не искажают- ся (византийские сбои не рассматриваются — об эгом позже). ♦ Crash-recovery модель отказов. Узлы могул падать и восстанавливаться. После восстановления узел имеет дос- туп к persisient storage (системе хранения данных)— данные на диске не теря- ются. ♦ Любое число узлов может упасть, но большинство должно работать. Для N узлов нужно, чтобы хотя бы [N/2J + 1 было доступно. При пяти узлах пе- реживаем падение двух. Paxos определяет три роли. Один физический узел может исполнять несколько та- ких ролей: ♦ Proposer (предлагающий) — инициирует процесс консенсуса, предлагая значе- ние. Может быть несколько proposers одновременно. ♦ Acceptor (голосующий)— голосует за предложения. Коллективно acceptors «помнят» принятое решение. Должно работать большинство acceptors. ♦ Learner (обучаемый)— узнает о принятом решении. Не участвует в голосова- нии, только получает результат. Одна инстанция Paxos Сначала рассмотрим «базовый» Paxos, который решает консенсус для одного зна- чения. Это фундамент, на котором строятся практические системы
Задача, несколько proposers предлагают значения (возможно, разные). Нужно вы- брать ровно одно значение, и все узлы должны узнать, какое. Номера предложений (proposal numbers) Ключевая идея Paxos— каждое предложение имеет уникальный номер. Номера глобально упорядочены: любой узел может сравнить два номера и определить, ка- кой больше. Простой способ генерации: <seq’uence_numDer, node idx Сравниваем сначала по sequence number, при равенстве — по node_id (ЛИСТИНГ 4.1). type Proposal Number struct { Sequence int NodelD string func (p ProposalNumber) GreaterThan(other Proposal Number) bool { if p.Sequence != other.Sequence { return p.Sequence > other.Sequence return p.NodelD > other.NodelD Каждый proposer поддерживает свой счетчик и увеличивает его перед каждой но- вой попыткой. Протокол: две фазы Paxos состоит из двух фаз. Prepare и Accept. Фаза 1: Prepare Proposer хочет предложить значение V. Он генерирует новый номер N (больше лю- бого, который он использовал раньше) и отправляет сообщение Prepare (N) всем acceptors: message Prepare { proposal number: N } Acceptor, получив Prepare (N): ♦ если N больше любого номера, на который он уже отвечал, он обешает больше не принимать предложения с номером меньше N; ♦ отвечает Promise (N, accepted-proposal, accepted_value) :
• accepted joropcsal, accepted vaiue— номер и значение предложения, которое этот acceptor уже принял ранее (если есть), ♦ Если N не больше уже виденного — игнорирует или отвечает отказом (листинг 4.2). // Состояние acceptor'a (persistent) type Acceptorstate struct { PromisedNumber ProposeINumber // // AcceptedNumoer ProposalNumber // Acceptedvaiue interfaced // максимальный номер, на который обещали номер принятого предложения принятое значение } func (a ‘Acceptorstate) HandlePrepare(n FroposalNunber) ‘Promise { if n.GreaterThan(a.PromisedNumber) { a.PromisedNamber = n // обещаем не принимать меньшие номера // ВАЖНО: записать на диск перед ответом! a. persist () return SPromisef PropcsalKumber: n, AcceptedNumocr: a.AcceptedNumber, Acceptedvaiue: a.Acceptedvaiue, } } return nil // отказ или игнорирование } Proposer ждет ответов от большинства acceptors. Если пол^-чил — переходит к фазе 2. Фаза 2: Accept Proposer получил обещания от большинства. Теперь он должен выбрать значение для предложения: ♦ если все ответившие acceptors не принимали никаких значений ранее (accepted vaiue = null), proposer свободен предложить свое значение V; ♦ если хотя бы один acceptor уже принимал значение, proposer обязан предложить значение с наибольшим номером среди полученных (листинг 4.3). func (р ‘Proposer) chooseValue(promises [(Promise, myVaiue interface!}) interface}} { var nighestAccepted ‘Promise for premise := range promises { if promise.Acceptedvaiue != nil { if highescAccepted = nil | |
promise.AcceptedNumber.GreaterThan(highestAccepted.AcceptedNumbei) { highestAccepted = &prcmise if highestAccepted != nil { return highestAccepted.AcceptedVaJue // обязаны // использовать это! return myValue // свободны предложить свое Proposer отправляет Accept(N, V) всем acceptors: message Accept { proposa^_number: N value: V Acceptor, получив Accept (N, V): ♦ если N >= promised number (номер не меньше обещанного), он принимает предложение: записывает (N, V) и отвечает Accepted(N, V); ♦ иначе — отклоняет, поскольку уже обещал большему номеру (листинг 4.4). func (a *AcceptorState) HandleAccept(и ProposalNumber, v interface!}) *Accepted { if !a.PromisedNuirber.GreaterThan(n) { // n >= promised a.Acceptedbuirber = n a.Accepted!alue = v a.PrcmisedNumber = n // также обновляем promised // ВАЖНО: записать на диск перед ответом! a. persist () return 4Accepted{ProposalNuraoer: n, Value: v} } return nil // отклонено Когда proposer получает Accepted от большинства— консенсус достигнут. Значе- ние V выбрано. Завершение: Learn Learners должны узнать о выбранном значении. Варианты: ♦ Acceptors отправляют Accepted всем learners. ♦ Один ведущий learner собирает ответы и рассылает остальным. ♦ Learners периодически опрашивают acceptors.
Почему это работает? Ключевой инвариант Paxos: Если предложение с номером N и значением V было принято большинством, то любое предложение с номером М > N будет иметь значение V Докажем неформально: ♦ Предложение (N, V) принято большинством acceptors — назовем это множество Q1. ♦ Любое будущее предложение М > N должно получить обещания от большинст- ва — множество Q2. ♦ Q1 и Q2 пересекаются (оба— большинства). Существует acceptor А, который и принял V, и обещал М. ♦ Когда proposer с номером М получит обещание от А, он увидит (N, V) в ответе. ♦ По правилам протокола proposer обязан выбрать значение с наибольшим номе- ром — то есть V (или значение еще более позднего принятого предложения, но по индукции это тоже V). ♦ Следовательно, предложение М будет иметь значение V. Два большинства всегда пересекаются — это фундамент корректности Paxos. Пример выполнения Рассмотрим сценарий с тремя acceptors (Al, А2, АЗ) и двумя proposers (Pl, Р2). Сценарий 1: простой случай Р1 хочет предложить значение "foo" (рис. 4.3). Рис. 4.3
Pl получил большинство. Никто не принимал значений. Р1 выбирает свое значение "foo" (рис. 4.4) Рис. 4.4 Консенсус достигнут; значение "foo". Сценарий 2: конкурирующие proposers Pl хочет "foo", Р2 хочет "bar", работают одновременно (рис. 4.5). Prepare(l) Сообщение к А? сдержалось Рис. 4.5 Р1 получил большинство, переходит к Accept (рис. 4.6). Р2 получил большинство (рис. 4.7). Р1 получил Accepted от Al, АЗ — это большинство!
Р2 испольтугт 5с пыиий номер (2' и псиучае^ тбеш чия от А2 и АЗ А2 теперь обещал номер 2 Рис. 4.6 Р1 пьп ается отправить Accept 1, *fbo*): • Al приник ит (не обещал никому другому) • А2 отклоняет (уже обе щал номер 2) • АЗ принимает * не видел Prepared. вовремя) Рис, 4.7 Значение "foo" выбрано (рис 4.8) Р1 уже получил большинство для "foo", но Р2 еще не знает об этом. Однако ♦ Когда Р2 делла Ргераге(2). он получил ответы от А2 и АЗ, ♦ АЗ к тому моменту' уже мог принять (1, "foo")! ♦ Тогда Р2 увидит В Promise (2, accepted_r’jmber=l, accepted_value="foo"). ♦ Р2 обязан предложить "foo", не "bar"!
I Al (Acceptor) ! ________________। P2 отправляет Accept(2, "bar") к A2 и A3 A2 уже обещал номер 2, но должен ли ой принять ’bar’? Рис. 4.8 Рассмотрим этот случай подробнее (рис. 4.9): Сцешрв* Proptre(2) от Р2 к АЗ пришел ПОСЛЕ Accept(l, *foo") АЗ уже цжш (1, Too") ж вопращвет это ж Promise Р2 обязан шбратъ уже щянопое значеше ”foo* шесто своего "bar* Рис. 4.9 Результат: значение "foo'‘ подтверждено еще раз. Алгоритм «помнит» уже выбранное значение через механизм возврата accepted value в Promise Проблема прогресса: дуэль proposers Paxos гарантирует safety (никогда не выберет два разных значения), но не гаранти- рует liveness (может не завершиться). Рассмотрим патологический сценарий (лис- тинг 4.5). Pl: Prepare(1) -> получил большинство Р2: Prepare(2) -> получил большинство, все обещали 2
Pl: Accept(1, "foo") -> отклонено! все обещали >= 2 Pl: Prepare(3) -> получил больдинствэ Р2: Accept(2, "bar") -> отклонено1 все обещали >= 3 Р2: Prepare(4) -> получил большинство Pl: Accept(3, "foo") -> отклонено! ...и так до бесконечности Proposers «перебивают» друг друга бесконечно. Это не нарушает коррекзность (значение не выбрано — это допустимо), но система не прогрессирует. Решение: выделенный лидер. Только один proposer активен в текущий момент. Остальные перенаправляют ему запросы. Если лидер упал — выбирается новый (используя... Paxos!). Это подход Multi-Paxos. Multi-Paxos: от одного значения к журналу Базовый Paxos решает консенсус для одного значения. Но реальным системам нуж- на репликация журнала — последовательности команд. Multi-Paxos запускает отдельную инстанцию Paxos для каждой позиции в журнале: Позиция 1: Paxes instance -> выбрано "SET X=l" Позиция 2: Paxos instance -> выбрано "SET Y=2" Позиция 3: Paxos instance -> выбрано "DELETE X" Наивно это требует 2 раунда сообщений на каждую команду (prepare + Accept). Оптимизация: стабильный лидер. Если лидер не меняется, он может «переиспользовать» фазу Prepare: ♦ лидер один раз выполняет Prepare для «всех будущих позиций»; ♦ для каждой новой команды — только Accept (один раунд!); ♦ если лидер сменился — новый лидер делает Prepare (листинг 4.6). Листинг 4.i Il Оптимизированный Multi-Paxos Leader делает Prepare(N) один раз Команда 1: Accept(N, pcs=l, cnd="SET X=l") -> Accepted Команда 2: Accept(N, pos=2, cnd-"SET Y=2") -> Accepted Команда 3; Accept(N, pos=3, cmd="DELETE X") -> Accepted // При смене лидера New Leader: Prepare (N+l) для всех позиций -> узнает о всех принятых значениях -> продолжает с Accept(N+1, ...)
Это основа большинства промышленных реализаций: Google Chubby, Apache ZooKeeper (ZAB — модификация Multi-Paxos) и других. Практические соображения ♦ Персистентность. Acceptor должен записать состояние на диск до отправки ответа. Иначе при кра- хе и восстановлении он может нарушить свои обещания (листинг 4.7). | Листинг 47 func (a *Acceptor) HandlePrepare (n ProposalNurrber) *?romise { if n.GreaterThan(a.Proir.isedN’urrber) { a.FromisedNumber = n // ОБЯЗАТЕЛЬНО: fsyr.c перед ответом! if err := a.persistToPisks(); err != nil { return nil // не отвечаем, если не смогли сохранить } return &Promise{...} } return nil 1 func (a *Acceptor) persistToDiskO error { data := encode(a.PromisedNumber, a.Acceptedlurnber, a.AcceptedValue) if err := os.WriteFiiefa.stateFile, data, 0644); err != nil { return err } // fsync гарантирует, что данные на диске, а не в кеше ОС f, _ := cs.Open(a.stateFile) defer f. Close () return f.SyncO } ♦ Таймауты и ретраи. Paxos не гарантирует завершение, но практические реализации используют тайм- ауты. Если proposer не получил ответы за Т секунд — повторяет с большим но- мером. ♦ Выбор лидера. Нужен механизм определения текущего лидера и детектирования его отказа. Обычно используются lease (аренда): лидер периодически «продлевает аренду», если перестал — считается упавшим.
♦ Оптимизации. • Пакетирование, несколько клиентских команд в одном Accept. • Параллельные инстанции', не ждать завершения позиции N, чтобы начать N+1. • Гибкие кворумы', для чтения можно опрашивать меньше узлов при опреде- ленных условиях. Недостатки Paxos При всей своей фундаментальности Paxos имеет серьезные проблемы: ♦ Сложность понимания. Даже эксперты путаются в деталях. Оригинальные статьи Лэмпорта неполны — не описывают многие практические аспекты (смена лидера, реконфигурация кластера, снэпшоты). ♦ Нет референсной реализации. Лэмпорт опубликовал идеи, но не код. Каждая команда реализует «свой» Paxos, часто с ошибками. ♦ Неспецифицирооанные детали. Как именно выбирать лидера? Как добавлять узлы в кластер9 Как обрезать жур- нал? Статьи молчат, каждый изобретает свое. Эти проблемы мотивировали создание Raft— алгоритма, спроектированного с упором на понятность. Raft: консенсус для смертных В 2014 году Диего Онгаро и Джон Оустерхаут из Стэнфорда опубликовали статью «In Search of an Understandable Consensus Algorithm» Название говорит само за се- бя: авторы не претендовали на теоретические прорывы Их цель — создать алго- ритм, который можно поня ть. И у них получилось. Raft стал де-факто стандартом для новых распределенных сис- тем. Его используют eted (хранилище Kubemctes). Consul (HasbiCorp), CockroachDB, TiKV и десятки других проектов. Существуют реализации на всех популярных языках, многократно проверенные и оптимизированные. Философия Raft Paxos идет от абстракции: вот формальная задача консенсуса, вот доказуемо кор- ректное решение. Практические детали — упражнение для читателя. Raft идет от практики: вот, что нужно реальной системе (реплицированный журнал), вот конкретный протокол со всеми деталями. Корректность следует из дизайна.
Ключевой принцип; декомпозиция. Вместо одною монолитного алгоритма— не- сколько независимых подзадач: ♦ Выбор лидера (leader election). ♦ Репликация журнала (log replication), ♦ Безопасность (safety). ♦ Изменение состава кластера (membership changes). Рассмотрим каждую. Роли и термы В Raft каждый узел в каждый момент времени находится в одном из трех состояний: ♦ Leader — обрабатывает все клиентские запросы, реплицирует журнал. ♦ Follower — пассивен, только отвечает на запросы лидера и кандидатов. ♦ Candidate — пытается стать лидером. Время делится на термы (terms) — логические эпохи, пронумерованные последо- вательно. Каждый терм начинается с выборов. Если выборы успешны — терм про- должается с одним лидером. Если нет (split vote) — начинается новый терм с но- выми выборами (листинг 4.8), type NodeState int const ( Follower NcaeState = iota Candidate Leader type RaftNode struct { // Persistent state (сохраняется на даек) currentferm int // текущий терм votedFor string II кому отдали голос в этом терме (или log (JlogEntry // журнал команд // Volatile state state NcdeState conimitlndex int li индекс последней закоммиченной записи lastApplied xnt // индекс последней примененной записи /7 Leader-only volatile state nextindex map[string]int // для каждого follower следующий Il индекс для отправки
matcblndex map[string]int // для каждого follower: последний /I реплицированный индекс } type LogEntry struct { Term int // терм, ь котором создана запись Index int // позиция е журнале Command interface(} // команда для state machine } При этом currentTerm, votedFor и log должны бьпь персистентными. Запись на диск выполняется перед ответом на любое сообщение! Выбор лидера Followers ожидают от лидера периодических коротких сообщений (heartbeat). Если heartbeat не приходит за election timeout — follower считает лидера мертвым и на- чинает выборы. Узел начинается как Подписчик (Follower). Он просто ждет сигналов от лидера — регулярных коротких сообщений (heartbeat) или команд на обновление данных. Ес- ли лидер слишком долго молчит, и таймер ожидания срабатывает, подписчик ре- шает. что лидер пропал, и сам становится Кандидатом (Candidate). Став кандидатом, узел начинает выборы Он объявляет новый номер раунда, голо- сует за себя и просит все остальные узлы проголосовать за него. Дальше есть три варианта развития: ♦ Если большинство узлов его поддержали — он побеждает и становится Лиде- ром (Leader). ♦ Если он получает сообщение от другого, уже действующею лидера— он признает его и снова становится Подписчиком. ♦ Если таймер выборов истек, а ясности нет — он начинает новый раунд выбо- ров с самого начала. Когда узел становится Лидером, его задача — вести за собой кластер. Для этого он постоянно напоминает о себе короткими сообщениями (heartbeat), чтобы никто не начал новые выборы. Он принимает запросы от клиентов и следит, чтобы все изме- нения в данных были аккуратно скопированы на остальные узлы, поддерживая по- рядок и согласие в системе. Фаза выбора лидера: RequestVote RPC (листинг 4.9). Листинг 4.9 ' type RequestVoteArgs struct { Term int // терм кандидата CandidatelD string II ID кандидата
LastLoglndex int // индекс последней записи кандидата LastLoaTerm int // терм последней записи кандидата } type RequestVoteReply struct { Term int // текущий терм (для обновления кандидата) VoteGranted bool // true = голос отдан } func (n *RaftNode) hand!eRequestVote(args RequestVoteArgs) RequestVoteReply { reply RequestVoteReply(Term: n.currentTem} // Правило 1: отклоняем устаревшие термы if args.Term < n.currentTerm { reply.VoteGranted = false return reply } 11 Если видим больший терм — обновляемся if args.Term > n.currentTerm { n.currentTerni = args. Term n.votedFor = "" n.state = Follower } // Правило 2: голосуем, только если еще не голосовали // (или уже голосовали за этого кандидата) if n.votedFor != "" && n.votedFor != args.CandidateID { reply.VoteGranted = false return reply ) 11 Правило 3: кандидат должен иметь "не менее полный" журнал // (election restriction — ключевое для safety!) if !n.isLogUpToDate (args.LastlogTenr., args.LastLoglndex) { reply.VoteGranted = false return reply } // Голосуем! n.votedFor = args.CandidacelD n.persist () reply.VoteGranted = true reply.Те rm = n.currentTerm // Сбрасываем election timer — .лидер жив (кандидат, // но это тоже активность) n.resetElectionTimer ()
return reply ) func (n '"Ra ft Node) isLog'JpToDace (candidateLdstTerrr., candidateLastlndex int) bool { mybastlndex := len(n.log) - 1 myLastTerm := 0 if mybastlndex >= 0 { myLastTerm = n.log[mylastlndex].Term } // Сравниваем по терму последней записи, затем по длине if candidateLastTerm 1= myLastTerm { return car.didateLastTerm > myLastTerm } return candidateLastlndex >= myLastlndex } Правило 3 (Election restriction) гарантирует, что лидером может стать только узел, чей журнал содержит все закоммиченные записи. Без него возможна потеря дан- ных. Рандомизация таймаутов: чтобы избежать бесконечных split votes (несколько кандидатов делят голоса), каждый узел выбирает election timeout случайно из диа- пазона. например. [150ms, 300ms], Это практически устраняет одновременные вы- боры; func (n *RaftNcde) resetElectionTimer() { timeout := 150 + rand.Intn(150) // 150-300m.s n.electionTimer.Reset(time.Duration(timeout) * tine.Millisecond) } Репликация журнала После избрания лидер начинает принимать команды от клиентов и реплицировать их на followers. AppendEntries RPC Фаза AppendEntries RPC (используется и для репликации, и для heartbeat) (лис- тинг 4.10). type AppendEntriesArgs struct { Term int // терм лидера LeaderID string // ID лидера PrevLoglndex int // индекс записи перед новыми PrevLogTerm int // теом записи FrevLoglndex
Entries []LogEntry // новые записи (пусто для Heartbeat) LeaderCommit int II commitIndex лидера type AppendEntriesReply struct { Term int // текущий терм Success bool // true = follower согласован c PrevLog* // оптимизация для быстрого восстановления Conflictindex int // при конфликте: первый индекс конфликтующего терма ConflictTerm int // терм в Conflictindex ) Алгоритм follower's Алгоритм foliower'a приведен в листинге 4.11. func (n ’RaftNode) handleAppendEntries (args AppendEntriesArgs) AppendEntriesReply ( reply := AppendEntriesReply{Term: n.currentTerm, Success: false} // Отклоняем устаревшие термы if args.Term < n.currentTerm { return reply } // Признаем лидера, сбрасываем таймер n.currentTerm = args.Tent n.state = Follower n.resetElectioni’imer () Il Проверяем согласованность журнала it args.PrevLoglndex >= 0 { if args.PrevLoglndex >= len(n.log; { // У нас нет записи Prevloglndex reply.ConflicrIndex = len(n.log) reply.ConflictTerm = -1 return reply } if n.logLargs.PrevLoglndex].Term != args .PrevLogTenr. { // Терм не совпадает — конфликт! reply.ConflictTerm = n.log[args.PrevLoglndex].Term Il Ищем первую запись с этим термом for 1 := args.PrevLoglndex; i >= 0 n.log[i].Term == reply.ConflictTerm; i— { reply.Conflictlndex = i
return reply } } // Журнал согласован до PrevLoglndex // Применяем новые записи for i, entry := range args.Entries { index := args.PrevLoglndex + 1 + i if index < len(n.leg) { if n.]og[index].Term != entry.Term { // Конфликт — удаляем эту и все последующие записи n.log = n.log[:index] } } if index >= len(n.leg) { n.log = append(n.log, entry) } } n.persist () Il Обновляем commitIndex if args.LeaderCommit > n.commitlndex { n.commitIndex = min(args.LeaderCommit, len(n.log)-1) n.applyCommittea() // применяем к stare machine } reply.Success = true return reply Алгоритм лидера Алгоритм лидера приведен в листинге 4.12. Листинг 4.12 func (n *RaftNode) replicateToFollower(followerlD string) { for n.state == Leader { nextldx := n.nextindex[followerlD] prevLoglndex := nextldx - 1 prevLogTerm := 0 if orevLoglndex >= 0 && prevLoglnaex < len (n.log) { prevLogTerm = n.log[prevLoglndex].Term ) // Собираем записи для отправки entries := []LogEntry[)
if nextldx < len(n.leg) { entries = n.log[nextIdx;] } args := AppendEntriesArgs{ Term: n.currentTerm, LeaderID: n.id, PrevLoglndex: prevLoglndex, PrevLogTerm: prevLogTem, Entries: entries, LeaderCommit: n.commitindex, } reply := n.sendAppendEntries(followerlD, args) if reply.Term > n.currentTerm { // Обнаружили более новый терм — перестаем быть лидером n. currer.tTem = reply. Term n.state = Follower return } if reply.Success { // Записи приняты n.iratchIrdex[follcwerID] = prevLoglndex + len (entries) n.nextindex [followerlD] = n.matchlndex/followerlD] + 1 n. updateCommitlndex() } else { // Конфликт — откатываем next Index if reply.ConflictTerm == -1 { n.nextindex[followerlD] = reply.Conflictindex } else { // Ищем последнюю запись c ConflictTerm n.nextindex[followerlD] = reply.Conflictlndex for i := len(n.log) - 1; i >= 0; i— { if n.log[±].Term == reply.ConflictTerm { n.nextindex[followerlD] = i + 1 break } } } time.Sleep(heartbeatinterval)
func (n *RaftNc.de) updateCommitlndex() { // Запись заксммичена, если реплицирована на большинстве //И принадлежит текущему терму (важно для safety!) for idx := len(n.log) - 1; idx > n.comnutlndex; idx— ( if n.log!idx].Term != n.currentTerm { continue //не коммитим записи простых термов напрямую ) count := 1 // сам лидер for , matchldx := range n.matchindex { if matchldx >= idx ( count++ } ) if count > len(n.matchindex)/2 { n.commitlndex = idx n. aoplyt’ommitted () break ) } } Log Matching Property: свойство сопоставления с журналом Если две записи в разных журналах имеют одинаковый индекс и терм, то: ♦ Они содержат одинаковую команду. ♦ Все предыдущие записи тоже одинаковы. Это гарантируется следующими условиями: ♦ Лидер создает не более одной записи с данным индексом в данном терме. ♦ При AppendEntries проверяется согласованность по PrevLoglndex PrevLogTerm. ♦ При конфликте follower удаляет несогласованные записи. Leader Completeness Property: свойство безопасности Если запись закоммичена в терме Т, она присутствует в журнале лидера всех по- следующих термов. Это гарантируется election restriction: чтобы стать лидером, узел должен иметь журнал не менее полный, чем у большинства. Но заком миченная запись реплици- рована на большинство. Следовательно, кандидат с неполным журналом не полу- чит большинства голосов.
Почему лидер не коммитит записи прошлых термов напрямую? Это тонкий момент. Рассмотрим сценарий: Терм 1: Лидер S1 создал запись [idx=2, term=l], реплицировал на S2. S1 падает. Терм 2: Лидер S5 (не видел запись 2) создал запись [idx-2. 1еггп^2]. S5 падает до репликации Терм 3: S1 восстановился, стат лидером. Реплицировал [idx=2, term^l] на S3 —теперь на большинстве (SI, S2, S3). Можно ли закоммитить ’ Если да, и S1 падает, S5 может стать лидером терма 4. (у него [idx=2, term=2], что «новее» по терму). S5 перезапишет [idx=2] своей записью — потеря данных! Решение: лидер коммитит записи прошлых термов только косвенно— добавляя запись текущего терма и коммитя ее. Тогда election restriction гарантирует, что но- вый лидер будет иметь все записи. Кластерное членство: Joint Consensus Как добавить или удатить узел из кластера? Наивный подход (переключить конфи- I у рацию одновременно) нс работает — «одновременно» невозможно в распреде- ленной системе. Raft использует joint consensus — двухфазный протокол: ♦ Старая конфигурация: {А, В, С}. ♦ Новая конфш урация: {А, В, С, D, Е}. Фаза I: Переходная конфигурация C_old,new. Решения требуют большинства И в старой, И в новой конфигурации. Фаза 2: Новая конфигурация С new. После коммига C_old,new переключаемся на C new (листинг 4.13). type Configuration struct { Servers []string } type Jointconfiguration struct { Old *Configuration
New ^Configuration } func (jc *JodntConfiguration) HasQuorum(votes map[string]bool) bool { // Нужно большинство в обеих конфигурациях return ]c.01d.HasQuoruir.(votes) jc.New.HasQuorum(votes) } В любой момент времени кворум определяется текущей конфигурацией в журнале. Даже если часть узлов видит старую конфигурацию, а часть — переходную, систе- ма остает ся безопасной. Снэпшоты и компактификация журнала Журнал растет бесконечно, что непрактично. Решение — снэпшоты. ♦ State machine периодически сохраняет свое состояние на диск. ♦ Журнал до этого момента можно удалить. При восстановлении: затрузить снэппют, применить журнал после него (листинг 4.14). type Snapshot struct { Lastlncludedlndex int // последний индекс, включенный з снэпшот LastincludedTerm int // терм этого индекса Data []byte // сериализованное состояние state machine func (n *RaftNode) takeSnapshot(stateMachineDaca []byte) { snapshot := Snapshot{ Lastlncludedlnaex: n.lastApplied, LastincludedTerm: n.login.lastApplied].Term, Data: stateMachineData, } n.persistSnapshot(snapshot) // Обрезаем журнал n.log = n.login.lastApplied+1:] Для отстающих followers, чей nextindex указывает на удаленную часть журнала, лидер отправляет InstallSnapshot RPC.
Клиентское взаимодействие ♦ Линеаризуемоегь — требует, чтобы клиенты обращались только к лидеру. Как клиент находит лидера? • Обращается к любому узлу • Если это не лидер — узел перенаправляет к лидеру (или возвращает hint). • Если лидер упал во время обработки — клиент повторяет. ♦ Идемпотентность— клиент может повторить запрос, если не получил ответ. Чтобы избежать дублирования, выполняем следующий код (листинг 4.15). type ClientRequest struct { ClientID string // уникальный IC клиента RequestID int // последовательный номер запроса Command interface{} // ha сервере храним последний обработанный RequestID // для каждого клиента type Cession struct { LaatRequestID int LastRespcnse interface[} } Если запрос с таким Request id уже обработан — возвращаем сохраненный ответ. ♦ Read-only оптимизации — чтения не меняют состояние, нс- наивное чтение с лидера может вернуть устаревшие данные (лидер мог потерять лидерство и не знать об этом). Решения: • Readindex: лидер фиксирует текущий comniitlndex, подтверждает свое ли- дерство ЪеапЬеаг'ами, затем отвечает на чтение. • Lease-based reads: лидер имеет «аренду» на определенное время, в течение которого может отвечать на чтения без подтверждения. Полный пример: минимальный Raft на Go Соберем основные компоненты в работающий (упрошенный) код (листинг 4.16). package raft import ( "math/rand"
"sync" "time" const ( Heartbeatinterval = 50 * time.Millisecond ElectionTimeoutMin = 150 Election!:meoutMax = 300 ) type Raft struct { mu sync.Mutex 11 Идентификация id string peers []string // Persistent state currentTerm int votedFor string log []LogEntry // Volatile state state NodeState commitlndex int lastApplied int // Leader state next Index map I string]int matchindex map[string]int // Channels applyCh chan ApplyMsg 11 Timers electionTimer *time.Timer hearrbeatTimer *timc.Timer ) type ApplyMsg struct { ComrrandValid bocl Command interface{} Comrnandlroex int ) func (rf ’Raft) Start(command interface(}) (int, int, bool) ( rf .mu. Lock () defer rf.mu.Unlock()
if rf.state != Leader { return -1, -1, false } index := len(if.log) term := rf.currentTerm rf.log = append(rf.log, LogEntry{ Term: term. Index: index, Command: command, }) rf .persist () // Немедленно начинаем репликацию rf.broadcast AppendEnt ries() return index, term, true } func (rf *Raft) ticker() { for ( select ( case <-rf.electionTimer.C: rf .mu.LockO if rf.state != Leader { rf.startElection () } rf.resetElectionTuner () rf .mu.Unlock () case <-rf .neartbeatTiirer .C: rf mu.LockO if rf.state — Leader { r f.brcadcastAppendEntries() } rf heartbeacTimer.Reset(Heartbeatinterval) rf .mu. Unlock () ) } } func (rf *Raft) startElection() { rf.state = Candidate rf.currentTerm++ rf.votedFor = rf.id rf .persist!)
votes := 1 // голос за себя args := RequestVcteArgs) Term: rf.currentTerm, CandidatelD: rf.id, LastLoglndex; len(rf.log) - 1, J.astLogTerm: rf .lastLogTermO, } for peer := range rf.peers { go func (peer string) { reply := RequestVoteReply)} if rf.sendRequestVote(peer, Jargs, Sreply) { rf.mu. Lock () defer rf.mu.Unlock() if reply.Term > rf.currentTerm { rf.currentTerm = reply.Term rf.state = Follower rf.votedFor = "" rf .persist)) return } if rf.state == Candidate &4 reply.VoteGranted ( votes++ if votes > (len(rf.peers)+1)/2 ( rf.becomeLeader() ) } } }(peer) 1 } tunc (rf *Raft) oecomeLeaaer () ( rf.state = Leader // Инициализируем nextindex и raatchlndex rf.nextindex = make(map[string]int) rf.matchindex = make(map[string]int) for peer .= range rf.peers { rf.nextindex[peer] = len(rf.log) rf.matchindex[peer] = 0 } // Немедленный heartbeat rf.broadcastAppendEntries() )
func (rf *Raft) broadcastAppendEntries() { for peer := range rf.peers { go rf. sendAppendEntriesToPeer (peer) ) } func (rf *Raft) senaAppendEntriesToPeer(peer string) { rf .mu. Lock () if rf.state 1= Leader ( rf.mu.Unlock () return } nextldx := rf.nextindex[peer] prevLoglndex := nextldx - 1 prevLogTerm := C if prevLoglndex >= 0 && prevLoglndex < len(rf.log) { prevLogTerm = rf.log[prevLoglndex].Term } entries := make([]LogEntry, 0) if nextldx < len(rf.log) { entries = rf.log[nextldx:] } args := AppendEntnesArgs ( Term: rf.currentTerm, LeaderlD: rf.id, PrevLoglndex: prevLoglnaex, PrevLogTerm: prevLogTerm, Entries: entries, LeaderComrdt: rf.commitindex, ) rf .mu. Unlock () reply := AppendEntriesReplyi) if rf.sendAppendEntries(peer, &args, ireply) { rf. mu. Lock () defer rf.mu.Unlock() if reply.Term > rf.currentTerm { rf.currentTerm = reply.Term rf.state = Follower rf.votedFor = "" rf .persist () return
if rf.state != Leader || rf.currentTerm '= args.Term { return } if reply.Success { rf.matchindex[peer] = args.?revLogindex + len(args.Entries) rf.nextindex[peer] - rf.matchindex[peer] + 1 rf.updateCcmmitIrdex() } else { // Откат при конфликте rf.nextindex[peer] = max(l, rf,nextlndex[peer]-l) } } } func (rf *Raft) updateComir tIndex() { for n := len (rf.log) - 1; n > rf.commitindex; n— { if rf,log[n].Term != rf.currentTerm { continue } count := 1 for matchldx := range rf.matchindex { if matcbTdx >= n { count t-+ } ) if count > (len(rf.peers)+1)/2 ( rf.commitindex = n rf.applyLogs () break } ) } func (rf *Rafr) applyLogs() ( for rf.lastApplied < rf.commitlndex { rf.lastAppliedi + rf.applyCh <- ApplyMsg! CommandValid: true, Command; rf.loq[rf.lastApplied].Comnand, Commandindex: rf.las tApp1ied, ) ) }
func (rf *Raft) resetElectionT-jner () { timeout := ElectionTimcoutMin + rand.Intn(ElectionTimeoutMax-EiectionTjmeoucMinj rf.electionTimer.Reset(time.Duration(timeout) * time.Millisecond) } func (rf *Raft) lastLog'ferm() int { if len(rf.log) = 0 { return 0 } return Системы на Raft ♦ etcd — хранилище «ключ-значение», сердце Kubemetes. Каждый кластер Kubemetes использует etcd для хранения состояния: подов, сервисов, конфигу- раций, Реали зация Raft на Go, открытый исходный код. ♦ Consul — система service discovery от HashiCorp. Использует Rafi для консенсу- са между серверами, gossip-протокол для общения с клиентами. ♦ CockroachDB — распределенная SQL база данных. Каждый диапазон ключей (range) реплицируется своей Raft-группой. Тысячи Raft-групп в одном кластере. ♦ TiKV — распределенное хранилище, часть TiDB Также использует Rafi для ре- пликации ranges. Заключение Алгоритмы консенсуса— фундамент надежных распределенных систем. Paxos за- ложил теоретическую базу, Raft сделал ее практически применимой. Ключевые идеи, которые стоит запомнить из этой главы: ♦ Невозможность неизбежна. FLP-тсорема показывает, что идеального решения не существует Практические алгоритмы работают «почти всегда», но могут за- стревать в патологических случаях. ♦ Большинство — основа корректности. Любые два большинства пересекаются. Это простое наблюдение лежит в основе и Paxos, и Rafi ♦ Термы/номера предложений упорядочиваю! события. В отсутствие глобаль- ного времени логические часы (термы) позволяют определить, «что было раньше». ♦ Персистентность обязательна. Без записи на диск узел после восстановления может нарушить свои обещания. ♦ Лидер упрощает систему. Один лидер — один источник истины — меньше возможностей для конфликтов В следующей главе мы рассмотрим тему распределенных транзакций. А пока — можете попробовать реализовать свой Raft. Это лучший способ понять, почему он устроен именно так.
- Г ЛАВА 5 - Распределенные транзакции В предыдущих главах мы научились достигать консенсуса — договариваться о едином значении. Но реальные системы сложнее. Представьте перевод денег между двумя банками' нужно списать со счета в одном банке и зачислить на счет в дру- гом. Это не одно значение — это две операции в двух независимых системах, кото- рые должны выполниться атомарно: либо обе, либо ни одна. Пришло время распределенных транзакций — область, где простые на первый взгляд задачи порождают удивительно сложные решения. ACID в распределенном мире Напомню свойства ACID, которые гарантируют транзакции в классических базах данных: ♦ Atomicity (атомарность): транзакция либо выполняется полностью, либо не выполняется вовсе. ♦ Consistency (согласованность): транзакция переводит базу из одного коррект- ного состояния в другое. ♦ Isolation (изоляция): параллельные транзакции нс видят промежуточных со- стояний друг друга. ♦ Durability (долговечность): результат закоммиченкой транзакции сохраняется даже при сбоях. На одной машине эти свойства обеспечиваются механизмами СУБД: журналом транзакций (WAL). блокировками, MVCC. Но когда данные распределены по не- скольким узлам, каждый со своей базой, своим журналом, своими сбоями — задача усложняется на порядок. Проблема атомарного коммита Формализуем задачу'. Есть N участников (баз данных, сервисов), и каждый выпол- няет часть распределенной транзакции. Нужно гарантировать: ♦ Атомарность: все участники либо коммитят. либо откагывают.
♦ Согласованность: решение о коммите принимается, только если все участники готовы. ♦ Устойчивость к сбоям: система приходит к решению даже при падении части узлов. Кажется, это похоже на консенсус? Да, но есть важное отличие. В консенсусе дос- таточно большинства — меньшинство может быть проигнорировано. В атомарном коммите нужно единогласие1 если хотя бы один участник не может выполнить свою часть, вся транзакция должна откатиться Two-Phase Commit (2РС) Классическое решение — протокол двухфазного коммита, предложенный Джимом Греем в 1978 год}'. Идея интересна — разделить процесс на две фазы голосование и фиксацию Участники: ♦ Координатор — управляет протоколом, принимает решение. ♦ Прочие участники (participants) — выполняют части транзакции. Фаза 1: Prepare (голосование) Координатор отправляет всем участникам запрос Prepare. Каждый участник: 1. Выполняет свою часть транзакции (но не коммитит!). 2. Записывает данные в журнал так, чтобы можно было и закоммитить, и откатить. 3. Отвечает Yes (готов) или No (не могу). Ответ Yes — это обещание участник гарантирует, что сможет закоммитить. даже если упадет и восстановится. Фаза 2: Commit/Abort (фиксация) Координатор собирает ответы: ♦ Если все ответили Yes — отправляет commit всем участникам. ♦ Если хотя бы один ответил Nc или не ответил — отправляет Abort. Участники выполняют полученную команду и подтверждают выполнение (лис- тинг 5.1). 1 —* ... * .... " ”• ' ...... - • I. - " .... Листинг 5.1 // Псевдокод координатора func (с Coordinator) execute (tx Transaction) bool { // Фаза 1: Prepare votes -.= make (map[string]bool) for , p := range c.participants {
vote := р.Prepare(tx) vctes[p.ID] = vote if Ivote { c.abort(tx) return false 1 } // Фаза 2: Commit c.logDecision(tx, "COMMIT") // точка невозврата! for _, p := range c.participants { p.Commit(tx) ) return true } func (c ’Coordinator) abort(tx Transaction) { c.logDecision(tx, "ABORT") for , p := rance c.participants { p. Abort(tx) ) } // Псевдокод участника func (p ’Participant) Prepare(tx Transaction) bool { if !p.canExecute(tx) { return false } p. execute (tx) // выполняем, но не коммити!.' p.logPreparea(tx) // записываем в журнал return true ) func (р ’Participant) Commit(tx Transaction) { p.commitLocal(tx) p.LogComnitced(tx) p.releaseLocks(tx) } Проблемы 2PC Протокол выглядит простым, но скрывает серьезные проблемы. ♦ Блокировка при сбое координатора. Представим: координатор отправил Prepare, получил Yes от всех, записал реше- ние commit... и упал. Участники в состоянии uncertainty (неопределенности): они ответили Yes, держат блокировки, но не знают финального решения.
Участник не может самовольно откатить — вдруг координатор решил закомми- тить? Не можег закоммитить — вдруг координатор решил откатить? Остается только ждать восстановления координатора. Блокировки (и ресурсы) замороже- ны на неопределенный срок. ♦ Блокировка при сбое участника. Координатор отправит Prepare. Один участник не отвечает — он упал или сеть? Координатор не может ждать вечно, но и не может закоммитить без единогла- сия. Приходится откат ывать всю транзакцию из-за одного молчащего участника. ♦ Сетевое разделение. При partition (разделении сети) участники могут оказаться изолированы от коор- динатора. Опи застревают в prepared-состоянии, не зная решения. Даже если связь восстановится через час — всё это время ресурсы заблокированы. Пример: перевод денег Артем переводит 1000 рублей Еве. Артем в банке А, Ева в банке В (рис. 5.1). АВАРИЯ! Three-Phase Commit (ЗРС) Попытка решить проблему блокировки — добавить третью фазу. ♦ Фаза 1: CanCommit— координатор спрашивает, могут ли участники выпол- нить тран !акпию (без фактического выполнения). ♦ Фаза 2: PreCommil — если все согласны, координатор отправляет PreCormit. Участники выполняют транзакцию и переходят в prepared-состояние. ♦ Фаза 3: DoCommit — координатор отправляет финальный коммит.
Ключевое отличие в ЗРС участник в состоянии FreCommit знает, что все проголосо- вали Yes. При сбое координатора участники могут самостоятельно довести транзак- цию до коммита (если все в PreCormit) или откатить (если кто-то не дошел до PreCommit) (ЛИСТИНГ 5.2). 5.2 // Состояния участника в ЗРС const ( Initial = iota Waiting 11 после CanCommit Frecommitted // после PreCommit — можем завершить сами Committed Aborted ) Проблема ЗРС: протокол не работает при сетевых разделениях. Если сеть раздели- лась, разные группы участников могут принять разные решения. ЗРС предполагает, что сбои — ото падения узлов, а не partition В реальных сетях это неприемлемо. Saga В 2010-х годах индустрия пришла к пониманию: распределенные ACID- транзакции — слишком дорогое удовольствие Вместо них используют Saga — по- следовательность локальных транзакций с компенсациями Идея: вместо атомарного «всё или ничего»— серия шагов, каждый из которых можно отменить. Если шаг N не удался, выполняем компенсации для шагов N-1, N-2,... 1 (листинг 5.3). Листинг 5-3 // Бронирование путешествия type SagaStep struct { Name string Action func () error Compensate func() error } func executeSaga(steps []SagaStep) error { executed := []int{J for i, step := range steps { if err : = step.Action(); err != nil { // Откатываем в обратном порядке for j := len(executed) - 1; j >= 0; j— ( steps[executed[]]].Compensate() )
} executed = append(executed, i) ) return nil } Il Использование saga := []SagaStep{ {’•flight’, bookFlight, cancelFlight), {"hotel", boekHutel, cancelHctel}, {"car", hookCar, car.celCar}, } executeSaga(saga) Saga не дает изоляции. Промежуточные состояния видны другим транзакциям. Между бронированием рейса и отеля другой пользователь может увидеть частично завершенное бронирование. Подходы, основанные на Saga, предполагают два варианта решения: оркестрация или хореография. Оркестрация Центральный координатор (Saga Orchestrator) управляет всеми шагами, вызывает сервисы последовательно, обрабатывает ошибки (листинг 5.4). // Оркестратор func (о 'Orchestrator) BockTrip(req TripRequest) error { flightID, err := c.flightservice.Book(req.Flight) if err != nil { return err } hotellC, err := o.hotelservice.3ook(req.Hotel) if err != nil { o.flightservice.Cancel(flightID) // компенсация return err } carlD, err := o.carService.Book(req.Car) if err != nil { o.hetelServjce.Cancel(hotellD) o.f LightServj.ce. Cancel (flightID) return err }
Хореография Нет центрального координатора. Сервисы общаются через события. Каждый сервис знает, что делать при получении определенного события (листинг 5.5). // Flight Service fur.c (s *FlightService) OnTripRequestec(event TripRequested) { booking := s.book(event.Flight) s.publish(FlightBcokedfTripID: event.TripID, EookinglD: booking.ID}) } func (s TlighlSeivice) OnHotelBookingFailed(event HotelBockingFailed) ( s.cancel(event.TripID) // компенсация } // Hotel Service func (s *HotelService) OnFlightBooked(event FlightBooked) { booking, err := s.book(event.TripID) if err != nil ( s.publish (HotelBooKingFailed{TripID: event.TripID}) } else ( s.punlish(HotelBocked1TripID: event.TripID}) } Плюсы и минусы обоих вариантов сведены в табл. 5.1. Таблица 5.1 Подход Плюсы гинусы Оркестрация Логика в одном месте, neiKO понять Единая тонка отказа, связанность Хореография Слабая связанность, нет SPOF Логика размазана, сложно отлаживать ТСС: Try-Confirm-Cancel ] 1атгерн. популярный в финансовых системах. Каждая операция разбивается на три фазы (листинг 5.6): ♦ Try; резервирование ресурсов, проверка возможности. ♦ Confirm: подтверждение, фактическое выполнение ♦ Cancel: отмена резервирования.
tуре TCCService interface t Try(ctx context.Context, req Request) (reservationlD string, err error) Confirm(ctx context.Context, reservationlD string) error Cancel(ctx context.Context, reservationlD string) error } // Координатор ТСС func (c *TCCCoordinator) Execute<services [jTCCService, req Request) error { reservations := make(map[int]string) // Фаза Try for i, svc := range services { resID, err := svc.Try(ctx, req) if err != nil { c.cancelAll(se rvices, reser vat ions) return err } reservations[i] = resID } // Фаза Confirm for i, svc := range services { if err := svc.Confirm(ctx, reservations[i]); err != nil ( // Confirm не должен падать после успешного Try! // Но если упал — нужен ручной разбор или retry log.Error("Confirm failed, manual intervention needed") ) } return nil ) ТСС похож на 2PC, но работает на уровне бизнес-логики, а не базы данных. Резер- вирования могут иметь TTL если Confirm не пришел за N минут, ресурс освобож- дается автоматически. Outbox Pattern: надежная публикация событий Частая проблема: нужно обновить базу и отправить событие в очередь. Две систе- мы — атомарность не гарантирована (листинг 5.7) // ПЛОХО: не атомарно func (s *Service> Process(order Order) error { s.dt. Save (order) // 1. сохранили
s.queue.Publish(CrderCreated(...}) // 2. отг.равили // Что если упали межлу 1 и 2? Таблица outbox Решение: пишем событие в ту же базу, в таблицу outbox. Отдельный процесс чита- ет outbox и отправляет в очередь (листинг 5.8). Листинг 5.8 func (s ’Service) Process(order Order) error { return s.db.Transact ion(func(tx *sql.Tx) error { tx.Exec("INSERT INTO orders ...") tx.Exec("INSERT INTO outbox (event_type, payload) VALUES ...") return nil // атомарно! }) } // Отдельная горутина func (s ’Service) processOutbox() { for ( events := s.db.Query("SELECT * FROM cutbox LIMIT 100") for e : = range events ( if s.queue.Publishfe.Type, e.Payload) = nil ( s.db.Exec("DELETE FROM cutbox WHERE id = ?", e.ID) } } time.Sleep(ICO * time.Millisecond) ) } Идемпотентность В распределенной системе сообщения могут дублироваться. Retry после таймаута, переподключение к брокеру— причин много. Каждая операция должна быть идемпотентной: повторное выполнение дает тог же результат (листинг 5.9). Листинг 5.S // Идемпотентный обработчик func (s ’Service) HandlePayment(req PaymentRequest) error { // Проверяем, не обработан ли уже existing := s.db.FindByldempotencyKey(req.IdempotencyKey) if existing != nil ( return existing.Result // возвращаем сохраненный результат
// Выполняем операцию result := з-ргосеззсаутепт(req> // Сохраняем результат s.db.SaveResult(req.IdempotencyKey, result) return result } Клиент генерирует idemyotencyKey (обычно UUID) для каждой логической операции и передает его во всех retry. Сравним подходы Сравнение предст авленных в главе подходов приведено в табл. 5.2. Таблица 5.2 Подход Согласованность Изоляция Доступность Сложность 2РС Строгая Полная Низкая (блокировки) Средняя ЗРС Строгая Полная Чуть выше Высокая Saga Eventual Нет Высокая Средняя ТСС Eventual Частичная Высокая высокая Когда что использовать? ♦ 2РС — когда все участники в одной сети, контролируются вами, и критична строгая согласованность. Пример шардированная база данных в одном дата- центре. ♦ Saga— когда участники — независимые микросервисы, eventual consistency приемлема. Пример, e-commerce (заказ, оплата, доставка). ♦ ТСС — когда нужен контроль над резервированием ресурсов, есть бизнес- требования к отмене. Пример: бронирование билетов с удержанием мест. Избегать распределенных транзакций — лучшая стратегия. Если можно спроекти- ровать систему гак, чтобы связанные данные были в одном сервисе, — делайте гак. Распределенные транзакции— это компромисс. Нельзя получить атомарность, доступность и устойчивость к разделениям одновременно. Выбор зависит от требо- ваний бизнеса: что страшнее — временная несогласованность или недоступность системы?
- Глава 6- Криптографические алгоритмы На протяжении всей книги мы решали задачи обработки данных — сортировали, искали, сжимали, распределяли по узлам. Но негласно предполагали одно: данные принадлежат нам. Что никто не прослушивает канал связи, не подменяет сообще- ния, не выдает себя за другого. В реальном мире все иначе. Интернет — публичная сеть. Трафик проходит через десятки узлов, и каждый можег быть скомпрометиро- ван. Wi-Fi в кафе может подслушивать любой сосед. Даже в корпоративной сети администратор видит всё. Как же общаться безопасно? В этом нам помогает криптография — наука о защите информации. Она позволяет двум сторонам общаться так, чтобы никто трегий не понял содержимого, даже пе- рехватив все сообщения. Позволяет убедиться, что сообщение не изменили по до- роге Дает способ доказать, что письмо отправлено именно тем, за кого себя выда- ет. И, как увидим, опирается криптография на красивую математику. От Цезаря до Тьюринга: краткая история шифров Люди шифровали сообщения тысячелетиями. Юлий Цезарь сдвига.: буквы алфави- та на гри позиции: Л становилась D, В — Бит. д. Математически шифр Цезаря можно записать так' Е(х) = (х + 3) mod 26, где х — номер буквы. Примитивно, но в I веке до н. э., когда большинство людей едва умели читать, этого хватало. Следующий шаг сделал французский дипломат Блез де Виженер в XVI веке. Он предложат использовать не один сдвиг, а последовательность, определяемую клю- чевым словом Например, если ключ — KEY, то первая буква сдвигается на 10 (К), вторая — на 4 (Е), третья — на 24 (Y), четвертая — снова на 10 (К), и так цикличе- ски. Получается полиалфавитный шифр, одна и та же буква открытого текста в разных местах превращается в разные буквы шифротекста, путаница усиливается. Три столетия шифр Виженера считался несломаемым — le chiffre indechiffrable. Казалось, если ключ достаточно длинный, статистические закономерности языка (частота букв, популярные биграммы) размажутся и исчезнут. Интуиция ошиба- лась. В 1863 году прусский офицер Фридрих Касиски опубликован метод взлома. Он заметил: если в тексте встречаются одинаковые фрагменты, зашифрованные одним и тем же участком ключа, то в шифротексте появятся одинаковые подстро- ки. Расстояния между такими повторами, скорее всего, кратны длине ключа. Найдя
несколько повторов и вычислив наибольший общий делитель этих расстояний, криптоаналитик узнает длину ключевого слова. А при знании длины шифр Виже- нера сводится к набору независимых шифров Цезаря— каждый ломается частот- ным анализом за счтгганые минуты Так «невзламываемый» шифр пал спустя 300 лет. Этот эпизод демонстрирует главный принцип криптоаналита: атака всегда найдет слабое место. В случае Виженера слабостью оказались периодичность ключа и ста- тистика языка. Создатели шифра полагались на секретность алгоритма и длину ключа, но упустили повторяемость шаблонов. Современная криптография учиты- вает все мыслимые атаки — и всё равно новые уязвимости регулярно открываются Вторая мировая война стала золотым веком криптоанализа. Немецкая шифроваль- ная машина «.Энигма» использовала три (позже четыре) ротора — диска с электри- ческими контактами для перестановки букв. При каждом нажатии клавиши роторы проворачивались, меняя конфигурацию проводки, как одометр в автомобиле. До- полнительная панель («стеккер») позволяла попарно пересоединять буквы. Общее число настроек в военной версии «Энигмы» превышало 10л23 — астрономическая величина, исключавшая прямой перебор. Немцы были уверены в неприступности шифра. Однако британская команда Алана Тьюринга в Блетчли-Парке нашла подход. Они опирались на прогнозируемоегь части сообщений Например, многие перехва- ченные радиограммы пачина.шсь с «WETTERBERICHT» (метеосводка) или закан чивались «HF.ILHITLER». Зная фрагмент открытого текста и соответствующий шифротекст (такой фрагменз называли crib, «подсказка»), можно отсечь почти все варианты настроек роторов Для автоматизации Тьюринг спроектировал электро- механическую машину Bombe, «Бомба» перебирала миллионы конфигураций в час, и когда находили комбинацию, дающую осмысленный текст, оператор получал ключ на день. По оценкам историков, взлом «Энигмы» сократил войну на два года и спас миллионы жизней. После войны криптография ушла в тень —- стала прерогативой спецслужб. Граж- данские алгоритмы были либо слабыми, либо засекреченными АНБ США контро- лировало экспорт шифров как военную технологию. Бизнес и обычные граждане оставались беззащитны. Всё изменилось в 1970-х. Проблема распределения ключей (как безопасно пе- редать секретный ключ для шифрования?) казалась неразрешимой. Кажется, мыв тупике; чтобы передать ключ, уже нужен безопасный канал... Парадоксально, но решить эту задачу удалось путем изобретения нового типа шифрования В 1976 году Уитфилд Диффи и Мартин Хеллман (Станфорд) опубликовали работу «New Directions in Cryptography», предложив концепцию асимметричного шифрова- ния. Идея перевернула весь мир криптографии: создать пару ключей: публичный и приватный — так. чтобы, зашифровав сообщение публичным ключом, расшиф- ровать его можно было только соответствующим приватным. При этом, зная пуб- личный ключ, невозможно вычислить приватный в разумные сроки Проблема безопасного обмена ключами фактически исчезала — достаточно публично со- общить открытый ключ.
Через год. в 1977-м, трое исследователей из МП- Рон Ривест, Ади Шамир и Лео- нард Адлеман— предложили первую практическую реализацию этой идеи, на- званную по их фамилиям RSA. Алгоритм опирается на сложность факторизации: перемножить два больших простых числа легко, а воз разложить произведение на множители — вычислительно непомерно сложно (для достаточно больших чисел). RSA стал первым широко известным алгоритмом публичного ключа. В августе 1977 года журнал «Scientific American» опубликовал статью Ривеста, Шамира и Адлемана с вызовом криптоаналитикам: расшифровать сообщение, зашифрованное 129-значпым числом (RSA-129). На тот момент авторы оценивали, что перебор за- нял бы «миллионы лет» В итоге послание удалось вскрыть лишь через 17 лет кол- лективными усилиями сотен компьютеров — и то благодаря улучшению алгорит- мов факторизации. Это подтвердило стойкость RSA. В 2000 году истек срок службы старого американского стандарта симметричного шифрования DES, и Национальный институт стандартов США (NIST) провел от- крытый конкурс на его замену7. Пять лет криптографы всего мира ломали и улуч- шали кандидатов. Победил алгоритм Rijndael бельгийцев Иоана Даймена и Вин- сента Рэймена. утвержденный в 2001 году как стандарт AES (Advanced Encryption Standard). Впервые критически важный шифр выбирался публично, с открытым анализом уязвимостей — и это стало новой нормой. Сегодня AES защищает боль- шую часть трафика в Интернете. Фундаментальные понятия Прежде чем углубляться в алгоритмы, определим несколько базовых терминов и принципов: ♦ Открытый текст (plaintext) — исходные данные, которые нужно защитить. Не обязательно текст — это могут быть бинарные файлы, трафик, изображения. ♦ Шифротекст (ciphertext) — результат шифрования. Набор, казалось бы, слу- чайных битов, не дающих информации о содержимом без ключа. ♦ Ключ (key)— секретный параметр алгоритма. Безопасность шифра целиком держится на секретности ключа, не алгоритма. Последний пункт настолько важен, что выведен в отдельный принцип В 1883 году голландский криптограф Огюст Керкгоффс сформулировал шесть требований к военным шифрам. Второе требование гласило: система должна оставаться бэзипасней. даже если противнику известно всё. кроме ключа. Сегодня это называют принципом Керкгоффса Принцип кажется контрин туи гивным. Разве секретный алгоритм не безопаснее от- крытого? Практика показывает обратное Секретный алгоритм нельзя подвергнуть независимому аудиту — уязвимости остаются незамеченными, пока их не найдет противник. Публичный алгоритм изучают тысячи специалистов, соревнуясь в по- иске слабых мест. То, что выдержало годы открытого анализа, заслуживает' дове-
рия Все современные шифры: AES, RSA, CliaCha20, Curve25519 — полностью открыты. Их спецификации доступны любому. Безопасность обеспечивает только ключ. Американский математик Клод Шеннон, один из основоположников теории ин- формации. в 1949 году формализовал два свойства, которыми должен обладать хо- роший шифр Он назвал их confusion и diffusion — путаница и рассеивание. В пе- реводе на конкретику: ♦ Путаница (confusion) — связь между ключом и шифрогекстом должна быть запутанной, сложной. Небольшое изменение ключа должно непредсказуемо ме- нять шифротекст. Иначе, угадывая ключ, противник сможет сразу понять, в пра- вильную ли сторону он движется ♦ Диффузия (diffusion)— влияние каждого бига открытого текста должно рас- пространяться на многие биты шифрогекста. И наоборот, изменение одного символа в шифротексте должно хаотично отражаться на открытом тексте. Иде- ально, если смена одного бига входа меняет примерно половину битов выхода. Так статистические особенности исходных данных «растворяются» в шифре Современные алгоритмы достигают путаницы и диффузии, комбинируя простые операции: перестановки, замену символов, XOR с ключом — многократно, в раун- дах. Например, блочный шифр AES выполняет 10 таких раундов над 128-битным блоком. В каждом раунде байты проходят через таблицу замен (S-box) для путани- цы и перемешиваются между собой для диффузии. В итоге выходной шифротекст выглядит как случайный шум и крайне чувствителен к любым изменениям ключа или исходного сообщения. Разобравшись с базой, перейдем к двум главным семействам алгоритмов шифрова- ния: симметричным и асимметричным. Симметричное шифрование Симметричным называется шифрование, при котором для зашифрования и рас- шифрования используется один и тот же секретный ключ Предположим, Николай хочет отправить Ивану конфиденциальное сообщение, Он выбирает некоторый ключ— например, последовательность из 16 случайных байтов, и шифрует сооб- щение этим ключом. Иван, получив шифротекст, должен знать тот же ключ, чтобы расшифровать его. В отличие от дверного замка, где один ключ запирает, а другой (дубликат) отпирает, здесь ключ действительно один и тот же — поэтому шифр и называется симметричным. Плюсы симметричного подхода— скорость и эффективность. Алгоритмы работа- ют быстро, близко к пропускной способности памяти. Операции над битами и бай- тами легко распараллеливаются и аппаратно ускоряются (AES, например, имеет поддержку в современных CPU). Симметричное шифрование подходит для боль- ших объемов данных: диски, базы данных, каналы связи.
Минус — сложность управления ключами Если у вас 10 сотрудников, и вы хотите, чтобы каждый мог безопасно общаться с каждым, нужны ключи для каждой пары, то есть десятки разных ключей. А главное — эти ключи сперва надо как-то пере- дать между сторонами. Возникает курьез: чтобы поделиться секретным ключом, нужен уже существующий защищенный канал. Получается замкнутый круг На протяжении веков проблему' распределения ключей решали организационно, посыльными с дипломатическими почтами, встречами агентов, скрытной доставкой кодовых книг. Всё это неудобно, дорого и небезопасно К счастью, как мы уже рас- сказали, в 1970-х появилась альтернатива в виде асимметрических схем. Но прежде чем перейти к ним, посмотрим, какие бывают симметрические шифры и как они устроены. Одноразовый блокнот: идеал, недостижимый на практике Теоретически самый надежный шифр — это одноразовый блокнот (one-time pad). Он же — шифр Вернама. Пусть у Артема и Евы есть общий секрет — длинная слу- чайная битовая последовательность, не известная никому более. Чтобы зашифро- вать сообщение, Артем побитово складывает его с ключом по модулю 2 (операция XOR). Ева, получив шифротекст, применяет XOR с тем же ключом — и идентич- ный ключ отменяет сам себя, возвратная исходный текст (поскольку хфуфу = х). Этот шифр доказуемо неприступен: если ключ абсолютно случаен и не повторяет- ся, шифротекст не дает о сообщении вообще никакой информации. Перехватив XOR оригинала с ключом, противник может получить любой возможный открытый текст той же длины, если подобрать соответствующий ключ. Например, шифро- тексг 1010 может соответствовать и слову «ВОЙНА», и слову «МИР»— смотря, какой ключ взять. Без знания ключа все расшифровки равновероятны. Американ- ский математик Клод Шеннон доказал, что одноразовый блокнот обладает совер- шенной секретностью (perfect secrecy). Почему же мы не пользуемся шифром Вернама повсеместно? Проблема снова в ключах. Ключ должен быть такой же длины, как сообщение, и исчерпываться за один сеанс. Значит, чтобы переписываться, Артему и Еве надо заранее (очно) поде- литься гигабайтами секретных данных, да еше и делать это перед каждым новым разговором. Это нереально Одноразовые блокноты применяются лишь для сверх- критичных коммуникаций на высшем государственном уровне — там, где курьер с чемоданчиком ключей хоть как-то оправдан. Например, в годы холодной войны США и СССР для связи между лидерами обменивались лентами с реально случай- ными битами (на основе шумов диодов). Но даже тогда система дата сбой: в проек- те VENONA советские шпионы повторно использовали страницы одноразового блокнота, и американская разведка, обнаружив дубликаты, смогла прочитать ты- сячи зашифрованных телеграмм.
Потоковые и блочные шифры Чтобы приблизиться к идеалу одноразового блокнота, но без его неудобств, были придуманы потоковые шифры. Их идея: вместо хранения гигантского ключа ге- нерировать псевдослучайную гамму (поток бигов) из короткого ключа с помощью алгоритма. Далее всё как у Бернама — XOR гаммы с открытым текстом дает шиф- ротекст. Противник без ключа не сможет воспроизвести псевдослучайную после- довательность, а значит, не узнает исходное сообщение. Потоковые шифры работа- ли в старых беспроводных стандартах, GSM, стриминговых приложениях Один из известных — RC4 (1987) американского криптографа Рона Ривеста. Однако многие потоковые алгоритмы оказались уязвимы при неправильном использовании. На- пример, в протоколе Wi-Fi WEP (конец 1990-х) повторялись начальные значения для RC4, из-за чего ключ удавалось восстановить за несколько минут. Современ- ные стандарты (WPA2) ушли от чистых потоковых схем в сторону блочных шиф- ров и аутентификации. Гораздо популярнее сегодня блочные шифры. Они обрабатывают данные не по- битово, а фиксированными блоками— например, 128-битными. Функция шифро- вания принимает на вход блок открытого текста и выдает блок шифрованного, при- чем обратная функция (декрипт) существует и восстанавливает исходник из шиф- ротекста и того же ключа. Классический пример блочного шифра— уже упомянутый AES Он действует на блоках по 16 байтов. Если сообщение длиннее 16 байтов, его режут на блоки (рис 6.1). Рис. 6.1 Возникает вопрос как зашифровать длинное сообщение блоком? Можно просто применить алгоритм к каждому блоку независимо, но тогда повторяющиеся фраг- менты текста дадут одинаковые блоки шифротекста, что выдает структуру сообще- ния. Поэтому исгользуются пазличные режимы шифров ания (modes of operation). Самый простой — цепное шифрование блоков (СВС): перед шифрованием каж- дый блок ХСЖ’ится с предыдущим шифротекстом, чтобы связать их. Первый блок ХОК ’ится со случайным стартовым вектором (IV), который не секретен, но должен быть уникальным для каждого сообщения В итоге одинаковые фрагменты на разных позициях зашифруются по-разному.
Другой популярный режим — счетчик (CTR). Тут сам блочный шифр использует- ся как генератор гаммы выбирается случайный счетчик (Nonce), который шифру- ется ключом, давая псевдослучайный выход — его ХОЯ’ят с первым блоком сооб- щения. Затем счетчик увеличивается (как число) и шифруется снова, получается гамма для второго блока, и так далее. Режим CTR превращает блочный алгоритм в потоковый: длина сообщения может быть произвольной, нет накопления ошибок по блокам (рис. 6.2). Но есть 1ребование: Nonce никогда не должен повторяться при одном и том же ключе Если два разных сообщения зашифровать с одинаковым ключом и одним и тем же Nonce, то их гаммы совпадут, и XOR. двух шифротекстов устранит ключ вовсе— мы получим XOR двух открытых текстов Такая ошибка действительно [убила системы повтор IV в WEP, как уже сказано, ломал защиту Wi-Fi. Поэтому практическое правило: Nonce генерируют случайно и никогда не повторяют, часто его просто инкрементируют каждый раз. Замечу, что ни С ВС, ни CTR сами по себе не защищают от подмены сообщений. Они обеспечивают конфиденциальность, но не целостность. Противник не может прочитать шифротекст, но может его видоизменить, и получатель в CBC/CTR этого не заметит. Например, режим CTR унаследовал свойство потоковых шифров: изме- нив один бит в шифротексте, мы получим предсказуемое изменение соответст- вующего бига открытого текста (в XOR всё линейно). Злоумышленник может це- ленаправленно исказить понятное ему поле сообщения (скажем, сумму транзак- ции) — не зная остального текста. Чтобы предотвратить такие атаки, данные аутентифицируют— добавляют код имитовставки (МАС) или используют специальные режимы с одновременным шифрованием и аутентификацией. Пример последнего — режим GCM, очень распространенный сеюдня (TLS, HTTPS). GCM вычисляет вместе с шифролекстом т. н. тег — значение, зависящее и от ключа, и от всего текста. Без правильного тега расшифровка не производится, а подделать тег практически невозможно (угадать шанс 1 из 2Л128). Мы еще поговорим об аутентификации и хеш-функциях в сле- дующих главах, а пока — пример симметричного шифрования на Go.
Реализация AES на Go Современные алгоритмы, как правило, дос гупны в стандартных библиотеках — не нужно писать их вручную. Давайте зашифруем сообщение с помощью AES-256 в режиме GCM, используя стандартный пакет Go crypto/aes и сопутствующий crypto/cipher. GCM хорош тем, что обеспечивает и шифрование, и контроль цело- стности. Мы сгенерируем случайный ключ, зашифруем строку и затем убедимся, что расшифровка возвращает исходный текст (листинг 6.1). Листинг 6.1 import ( "crypto/aes" // блочный шифр AES "crypto/cipher" // режимы шифрования, тут используем GCM "crypto/rand" // криптографически стойкий генератор // случайных чисел "encoding/hex" // для печати шифротекста в шестнадцатеричном виде "fmt" "io" ) func main () { // Открытый текст для примера plaintext := []byte("Attack, at dawn!") // можно использовать // не-ASCII, но для простоты генерируем случайный 256-битный // ключ (32 байта) для AES-256 key := make([]byte, 32) if _, err := rand.Read(key); err != nil { panic(err) ) // Создаем блочный шифр AES block, err := aes.NewCipher(key) if err != nil { panic (err) } // Создаем режим GCM на основе AES-блока aesGCM, err := cipher.NewGCM(block) if err != nil { panic(err) } // Генерируем случайный вектор-Nonce нужной длины (12 байт дпя GCM) попсе := таке([Ibyte, aesGCM.NcnceS:ze ()) if _, err := lo.ReadFull(rand.Reader, nonce); err != nil { panic (err)
// Шифруем: функция Seal пифрует plaintext и добавляет // аутентификационный тег ciphertext := aesGCM.Seal(nil, nonce, plaintext, nil) // Расшифровываем обратно для проверки decrypted, err := aesGCM.Open(nil, nonce, ciphertext, nil) if err != nil { panic(err) } // Выводим в консоль результаты (в реальном коде // вместо fmt.Printf были бы return значения) fmt.Printf("Plaintext: %s\n", plaintext) fmt.Printf("Ciphertext (hex): %s\n", hex.EnccdeToString(ciphertext)) fmt.Printf("Decrypted: %s\n", decrypted) } В production-коде не принято panic при ошибках, лучше возвращать их наверх Также строки "Attack at dawn!" следует передавать как и byte, учитывая кодировки Однако суть примера — показать последовательность: генерация ключа, создание шифра, шифрование и расшифровку. Здесь мы используем стандартную библиотеку: пакет crypto/aes для алгоритма AES, crypto/cipher— для интерфейса cipher.aead, который предоставляет методы seal (зашифровать и добавить тег) и Open (расшифровать и проверить тег). Ключ и Nonce генерируются через crypto/rand, что крайне важно — недопустимо исполь- зовать псевдо-рандом math/rand для криптографии. Если запустить эту программу, она выведет следующее сообщение (листинг 6.2). Plaintext; Attack at dawn! Ciphertext (hex): Ie6c3f2a3... (шестнадцатеричные байты шифротекста и тега) Decrypted: Attack at dawn! Мы видим, что дешифрование вернуло исходную фразу, а шифро текст выглядит как хаотичный набор байтов. Благодаря GCM любое изменение шифротекста будет обнаружено: при попьпке open с измененными данными возникнет ошибка аутен- тификации. На практике для симметричного шифрования всегда старайтесь приме- нять алгоритм шифрования + МАС (как GCM, ChaCha20-Polyl305 и др.), чтобы гарантировать и конфиденциальность, и целостность. Стоит отметить, что в жизни разрабатывать свои симметричные алгоритмы вам. скорее всего, не понадобится— достаточно правильно использовать существую- щие. Исключение — учебные цели. Для понимания работы шифров полезно реали- зовать упрощенный алгоритм своими руками, но в системах надежнее полагаться на стандартные, проверенные временем реализации (в Go - это пакеты crypto/*).
Асимметричное шифрование Вернемся к проблеме распределения ключей, с которой мы столкнулись в симмет- ричных системах. Пусть снова Николай хочет безопасно связаться с Иваном, но у них нет общего секрета Они договорились о шифре (например. AES), но ключ-то откуда взять? Передать ключ открыто — значит сразу подарить его злоумышлен- никам. Отправить в зашифрованном виде — опять же, чем-то шифровать придется. Порочный круг. Решение пришло в 1976 году и изменило мир. Диффи и Хеллман предложили схему, позволяющую двум людям выработать общий секретный ключ, даже если всё общение между ними подслушивается. Более того, они могут заранее не знать друг друга. Это стало возможным благодаря использованию односторонних функций — математических операций, которые легко вычислить, но крайне труд- но обратить. Классический пример односторонней функции— умножение двух больших простых чисел: перемножить просто, а вот факторизовать результат прак- тически нереально, если числа большие. Аналогия: телефонный справочник, где номера отсортированы по именам. Найти по имени номер — секундное дело, а вот по номеру отыскать имя владельца — придется перебирать весь справочник, пото- му что прямая связь потеряна Так и с односторонними функциями: вперед быстро, обратно — лишь полным перебором. Диффи-Хеллман реализовали гениальную идею на основе односторонней функции возведения в степень по модулю простого числа. Представим упрощенно: Никита и Максим договорились использовать некоторое простое число р и основание g (эти значения можно объявить открыто). Далее: ♦ Никита придумывает секретное число а (его приватный ключ) и вычисляет А = g'u mod р. Максим придумывает свое секретное b и вычисляет В = g^b mod р. Эти А и В — публичные значения, их можно переслать открыто. ♦ Теперь Никита берет полученное от Максима В и возводит в свою степень а: вычисляет 5 = B'a modр. Никита делает симметричное: S = АЛЬ modр Что же получилось? Оказывается, у обоих сторон получилось одно и то же число 5. Действительно, подставив формулы, получим, что S у Никиты = (grb)''a mod р = g'Aab) mod р, и 5 у Максима = (gfa^b modр = g^(a-b) modр. Оба пришли к одному значению — общему секрету. При этом ни один из них не отправлял свой секрет напрямую. Дтя наблюдателя со стороны (назовем его Злой Иван) доступны только публичные g'ci modр и g'b modр. Чтобы найти из югх итоговое g' (a-b) modр, Ива- ну пришлось бы решить задачу вычисления дискретного логарифма, а это как раз односторонняя функция, обратная возведению в степень по модулю. Для достаточ- но больших р (2048 битов и выше) прямых методов ее решения неизвестно. Проще говоря, даже обладая огромными ресурсами, противник не сможет вычислить ключ S за разумное время. Для наглядности часто приводят аналогию со смешиванием красок. Артем и Ева публично договариваются о базовом цвете — скажем, желтом. Артем выбирает се- бе секретный цвет — красный, Ева — синий. Каждый смешивает секретный цвет с
общим желтым и отирав.[яег партнеру получившийся оттенок (оранжевый и голу- бой, соответственно). Ни Артем, ни Ева не раскрывают свой чистый секрет. Затем каждый добавляет полученный от другого оттенок к своему' секретному цвету. В итоге у обоих получается один и тот же конечный цвет (некий коричневатый). Зретьс лицо, наблюдающее только обмен оранжевым и голубым, не зная исходных красного и синего, никак не сможет восстановить итоговый цвет. Похоже на ма- гию! Алгоритм Диффи-Хеллмана (DH)— это протокол согласования ключа, а не шифрование сообшений само по себе. В конечном итоге обе стороны с помощью DH получают общий ключ — и дальше могут переключиться на симметричное шифрование (например, AES) с этим ключом. Именно так и работают беюпасные соединения в Интернете: при установлении, скажем, HTTPS, ваш браузер с серве- ром через модифицированный протокол DH договорятся о ключе, а потом шифру- ют график симметричным алгоритмом. Такая схема объединяет лучшее из обоих миров: асимметрия решает задачу обмена ключами, симметрия обеспечивает ско- рость на больших данных. Благодаря открытию Диффи-Хеллмана мы избавились от необходимости переда- вать секрет по тайным канатам. Но в этом протоколе всё же присутствует обмен (пусть и не секретными данными). А можно ли сделать еще смелее: чтобы Артем мог послать зашифрованное сообщение Еве вообще без предварительного обмена с ней ключами — зная тишь какую-то публичную информацию о Еве? Оказывает- ся, да. Это и есть асимметричное шифрование, также известное как криптогра- фия с открытым ключом. Принцип асимметричных алгоритмов— каждый участник генерирует пару клю- чей: открытый (public) и закрытый (private). Открытый ключ можно публиковать, где угодно, закрытый держится в тайне. Шифруем мы открытым ключом адреса- та, а расшифровать может только его парный закрытый ключ. Возникает образ: от- крытый ключ — как замок с номером, который Ева оставила в открытом доступе, любой может положить письмо в коробку и защелкнуть ее этим замком, но открыть замок может только тот, у кото есть ключ, то есть Ева. RSA был первым практическим алгоритмом такого рода. У прошенно его математи- ческая суть: открытый ключ — большое число .V, являюшееся произведением двух простых (р и q), и еще одно число е. Закрытый ключ — те же р, q (которые знает только владелец) и число d, являюшее мультипликативным инверсом е по модулю <р(ЛЭ (где тр — функция Эйлера). Чтобы зашифровать число М (сообщение, интер- претируемое как число), его возводят в степень е по модулю N: С = Мле mod N. Расшифрование — возведение С в степень d но модулю .V: М = CAd mod N. Алго- ритм опирается на односторонность: зная N и е, никто не сможет найти d (т. е. раз- ложить N на р и </) за разумное время. Размер N сейчас берут минимум 2048 би- тов — тогда даже с суперкомпьютерами факторизация займет миллиарды лет. RSA до сих пор широко применяется, например, при установлении TLS-сессий. Он также используется для цифровых подписей (об этом в следующих главах). Но у RSA есть минусы: он относительно медленный и требует работы с очень большими
числами На шифрование'расшифрование каждого сообщения тратятся сотни тысяч машинных инструкций. Кроме того, стойкость RSA не доказана: не исключено, что завтра кто-то придумает быстрый алгоритм факторизации (либо придет квантовый компьютер), и шифр рухнет. Современная альтернатива RSA — криптография на эллиптических кривых (ЕСС). Придумана в 1985 году Нилом Кобливем и Виктором Миллером, но в прак- тику вошла лишь к 2000-м. ЕСС дает те же возможности, что и RSA/DH, но при гораздо меньших размерах ключей. Ее безопасность основана на задаче дискретно- го логарифма на точках эллиптической кривой — тоже односторонней функции. Без углубления в математику отметим: 256-битный ключ на эллиптической кривой по стойкости эквивалентен -3072-битному ключу RSA. То есть ключи короче в 10- 15 раз. а операции быстрее. Это особенно важно для мобильных устройств и про- чих встраиваемых систем. Потому протоколы вроде TLS 1.3 используют ЕСС прак- тически всегда. Go имеет отличную поддержку ЕСС. С версии 1.20 в стандартной библиотеке поя- вился пакет cryptp/ecdh, упрощающий реализацию Диффи- Хеллмана на эллиптиче- ских кривых. Его API очень прост, все кривые реализуют единый интерфейс, по- этому переключиться, например, с NIST Р-256 на современную Х25519 можно од- ной строкой. Приведем пример, как Артем и Ева могут выработать общий секрет с помощью ECDH (листинг 6 3). 1Шстииг6,3 import ( “crypto/ecdh" // пакет для эллиптического Диффи-Хеллмана "crypto/rand" "fmt" ) func main() { // Выбираем коивую Р-256 (стандартная эллиптическая кривая secp256rl) curve := ecah.P256() // Генерируем по приватному ключу для Артема и Евы alicePriv, _ := curve.GenerateKty(rand.Reader) bobPriv, _ := curve.GenerateKey(rand.Reader) // Обмениваемся открытыми ключами (alicePriv.Public О, DobPriv.Public О) //и вычисляем общий секрет aliceShared, _ := alicePriv.ECDH(bobPriv.Public О) bobShared, _ := bobPriv.ECDH (alicePriv.Public ()) // Проверяем - общий секоет одинаков у обоих fmt.Printin("Артем и Ева получили один и тот же общий секрет:", string(aliceShared) == string(bobSharea)) fmt.Printf("Общий секрет (%d байт): %x\n", len(aliceShared), aliceShared)
Мы создали по паре ключей и вычислили общий секретный набор байтов aiiceshared и bobshared. В реальности Артем и Ева сразу пустили бы этот секрет на дело — например, взяв его хеш как ключ симметричного шифрования для даль- нейшей переписки Пакет crypto/ecdh позволяет столь же просто использовать кри- вую Curve25519: достаточно заменить ecdh.P256() на ecdh.x25519(), и код будет ра- ботать с другой кривой (Х25519 считается сейчас более безопасной и быстрой, чем стандартные NIST). На практике Диффи-Хеллмана и RSA часто комбинируют Например, гибридная схема шифрования; вы генерируете случайный симметричный ключ (скажем, для AES), шифруете им большой файл, а сам ключ шифруете открытым ключом адре- сата (RSA). Получателю достаточно сначала расшифровать маленький ключ при- ватным RSA, а потом уже открыть основной файл AES-ключом. Так делается в FGP, в протоколах TLS и др. Асимметрия — для обмена ключами, симметрия — для данных. Безопасность асимметричных схем зиждется на сложных математических задачах. Но бывают и неожиданно простые уязвимости, связанные с реализацией. Был в ис- тории даже провал: компания Sony в 2010 году неправильно использовала алгоритм эллиптической цифровой подписи ECDSA в PlayStation 3. В подписи ECDSA есть случайное число к, которое нельзя повторять для разных сообщений. Sony же сэкономила и стала генерировать квазипос гоянны и к. Хакеры, получив две разные подписи с одним к, смогли вычислить приватный ключ компании и начали подпи- сывать любые игры, фактически открыв консоль для пиратства. Ошибка прямо- таки тривиальная, а последствия необратимые достаточно один раз утечь приват- ному ключу, и отозвать его уже нельзя. Другой пример — padding oracle, атака, использующая утечки при расшифрова- нии. Изначально описана криптографом Сержем Водэнеем в 2002 году, много лет' считалась теоретической, но в 2010-м выяснилось, что веб-фреймворк ASP NET ей подвержен. Злоумышленник, отправляя специально искаженные зашифрованные сообщения и анализируя, какие ошибки возвращает сервер (различая неверный paddmg и неверный МАС), смог полностью вскрыть шифрованные cookies на сер- вере Microsoft. Атака не требовала подбора ключа, она эксплуатировала лишь ин- формационные утечки. Вывод: шифрование без аутентификации опасно, и разработчики должны быть крайне внимательны к тому, какие сообщения об ошибках возвращает система. Сейчас, к счастью, типовые библиотеки не дают совершить таких промахов; высо- коуровневые .API (тот же cipher.aead в Go) сразу делают и шифрование, и проверку целостности. Помните, что асимметричные алгоритмы требуют больших ключей и сложных вы- числений. А значит, они медленнее. Шифровать трафик RSA-методом побайтно никто не будет, для этого есть симметрия. В crypto/rsa даже есть ограничение: вы не можете зашифроват ь сообщение длиннее ключа (то есть -256 байтов для RSA-2048). А вот симметричные алгоритмы спокойно скармливают гшабайты. В табл. 6.1 при- ведены ориентировочные скорости популярных реализаций на одной машине.
Таблица 6.1 Размер ключа Скорость шифрования AFS-128 (GCM) 128 битов -5 Гбит/с на ядро ChaCha20-Polyl305 256 битов -3 Гбит/с на ядро (программно) RSA-2048 2048 битов -300 опсраций/с (0 0003 Гбит/с на 256 байтах) ECDH (Curve25519) 256 битов (кубит) -10000 операций/с (для обмена ключа) Из таблицы видно, что асимметрия дорогая. Поэтому ее стараются применять ми- нимально и только там, где без нее не обойтись (обмен ключами, электронные под- писи, распределение сертификатов). Всё остальное время данные находятся под защитой быстрых симметричных алгоритмов. Однако шифрование — лишь одна грань зашиты информации. Закрыв содержимое от посторонних, важно еще гарантировать, что сообщение не подменили и что от- правитель — тот, за кого себя выдает. Для этого служат хеш-функции и цифро- вые подписи Хеш-функции: MD5, SHA-256, Blake2, Argon2 Ках безопасно хранить пароли пользователей? Как убедиться, что загруженный файл не был поврежден или подменен злоумышленником? На первый взгляд, решения простые: пароли можно хранить в виде строк и сравнивать напрямую, а целостность файла проверять побайтно с эталоном. Однако на практике такие подходы нс рабо- тают. Пароли в открытом виде — катастрофа с точки зрения безопасности: достаточ- но одной утечки базы, и злоумышленники узнают все секреты пользователей. С фай- лами гоже непросто: прямое побайтное сравнение требует иметь под рукой оригинал. Если файл большой, это неэффективно, а если файл передается по сети, его содер- жимое могло измениться по пути. Нужен иной способ проверять «тождественность» данных, нс раскрывая их содержимое и не пересылая всё целиком. Решение появилось в виде специального преобразования— криптографической хеш-функции. Хенг-функция берет произвольные входные данные (будь то пароль длиной 10 символов или видеофайл на гигабайт) и вычисляет по ним сравнительно короткий «отпечаток» фиксированной длины — хеш-сумму. Эта хеш-сумма играет роль своеобразного идентификатора исходных данных. Главное, что для хорошей хеш-функции выполнены два требования. Во-первых, вычислить хеш из данных легко (иначе проверка заняла бы вечность). Во-вторых, невозможно по хешу вос- становить исходные данные или подобрать другие, дающие тот же хеш, по крайней мере, за разумное время. Хеш-функция превращает данные в «криптогра- фический фарш», из которого нельзя восстановить исходное «мясо». Как говорит- ся, из котлет корову не соберешь Именно поэтому пароль можно хранить в виде хеша. Проверка сводится к тому, что пользователь вводит пароль, система хеширует его и сравнивает с сохраненным значением. Если хеши совпали — пароль угадан правильно, но зная только хеш,
обратным путем пароль не узнаешь (если хеш-функция надежна). С файлами си- туация похожая, достаточно переслать или опубликовать хеш-сумму файла, чтобы любой мог убедиться в его целостности — пересчитав хеш от скачанного файла и сравнив с эталонным. Изменился хотя бы один бит в файле, и контрольная сумма нс совпадет. Возникает вопрос: что делает хеш-функцию криптографически надежной? Рас- смотрим теоретические основы, а затем углубимся в конкретные алгоритмы. Исторический путь: от CRC до Агдоп2 Идея свертки данных в короткий отпечаток возникла еще до эры компьютеров. Еще Галилей шифровал открытия анаграммами, чтобы доказать приоритет без раскры- тия сути, — фактически публиковал «отпечаток» сообщения, по которому позже можно проверить подлинность оригинала. С появлением компьютеров первые хеш-функции были не криптографическими, а скорее инженерными, — контрольные суммы для обнаружения ошибок при пере- даче данных. Например, CRC (циклический избыточный код) вычисляет краткий код на основе полиномиального деления и отлично ловит случайные сбои, но про- тив умышленных искажений он бесполезен. Злоумышленник легко изменит данные так. чтобы получить тот же CRC, потому что CRC — линейная функция, не обла- дающая защитой от подмены. Настоящие криптографические хеш-функции начали появляться в 1970 -80-х. Од- ним из пионеров был Рон Ривест из MIT, создавший серию алгоритмов MD2, MD4. а в 1991 году— MD5. MD5 быстро стал популярным: 128-битный хеш, высокая скорость, реализован во множестве приложений — от проверки целостности фай- лов до хранения паролей. Но криптография — поле постоянной битвы математиков с вычислителями. Уже в середине 1990-х появились первые намеки на слабости MD5. В 1996 году Ханс Доббертин нашел коллизию для «сжатия» MD5. Это еще не взлом всей функ- ции, но тревожный звонок: разные входные блоки могут давать одинаковый ре- зультат на промежуточном этапе. В 2004-м грянуло настоящее открытие: команда китайских ученых под руководством Сяоюн Вана за час вычислений на кластере нашла полноценную коллизию MD5. Проще говоря, они автоматически сгенериро- вали два разных сообщения с одинаковым хешем MD5. Требование стойкости к коллизиям, базовое для крипгохеша. было сломлено. Крипто1рафы забили тревогу: MD5 больше не удовлетворяет минимальным требованиям безопасносги. Замена была наготове: еще в 1995 году американский NIST стандартизовал алго- ритм SHA-1 (160-битный хеш), основанный на идеях MD4/MD5, но с усиленным дизайном. Несколько лет SHA-1 считался падежным, и после взлома MD5 именно SHA-1 рекомендовали для новых систем. Однако история повторилась даже быст- рее. чем ожидалось. Уже к 2005 году появились первые методы подрыва SHA-1, а в 2017 году совместно CWI (Амстердам) и Google осуществили атаку SHAltered — впервые нашли два разных PDF-документа с одинаковым SHA-1 хешем. Это потре-
боиало колоссальных вычислений (ПО лет работы GPU в эквиваленте), однако прецедент был создан. Авторитетный институт NIST объявил о скорейшем выводе SHA-1 из обращения Если MD5 «умер» в 2008-м, когда хакеры с его помощью подделали SSL-ссртификат, то час SHA-1 пробил в 2017-м с публикацией SHAtlered. И хотя SHA-1 применялся во многих системах (например, в Git для идентификации коммитов и в старых версиях TLS), его криптографическая надеж- ность теперь нулевая Еще в начале 2000-х, осознавая конечность ресурса SHA-1, NIST разработал семей- ство SHA-2 — в него входят SHA-256 (256 битов) и SHA-512 (512 битов) и укоро- ченные версии. Эти алгоритмы были опубликованы в 2001 году и до сих пор счи- таются криптостойкими. Ни одной практической коллизии для SHA-256 не найде- но. Все правительственные стандарты США с 2010 года требовали испольювать SHA-2 вместо устаревших предшественников. Тем не менее криптография не стоит на месте, параллельно NIST провел конкурс на новый алгоритм SHA-3, победителем которого стал Кессак (2012 г.). Но инте- ресным побочным результатом оказались финалисты конкурса — одни из лучших мировых хеш-функций. Среди них была функция BLAKE, на основе которой затем создали усовершенствованный вариант Blake! Авторы В1аке2 поставили цель: ал- горитм, настолько же надежный, как SHA-3, но быстрее SHA-2 и удобнее в приме- нении. Результат— Blake2b (для 64-битных платформ) и Blake2s (для 8/32-бигных устройств) В1аке2 выдает хеш длиной до 256 или 512 битов, поддерживает встро- енный ключ (то есть может работать как НМАС) и при этом опережает старые ал- горитмы по скорости. Недаром В1аке2 стали называть «современной заменой SHA- 1» — быстрой и безопасной Однако в наше время возник еше один фронт криптографической войны — хране- ние паролей. Классические хеш-функции вроде SHA-256 оказались слишком бы- стрыми для этой задачи. Если злоумышленник получит хеши паролей, он может перебрать миллиарды вариантов в секунду на GPU или даже создать ASIC для взлома (как в случае с SHA-256, используемом в майнинге биткоина, специализи- рованное железо очень дешевое и доступное). Нужно было замедлить хеширование пароля, причем замедлить именно для ата- кующего. В 2013-2015 годах прошел конкурс Password Hashing Competition (РНС), и победителем стал алгоритм ArgonZ. Его создали Алекс Бирюков с коллегами, а идея в том, чтобы использовать не только вычислительное время, но и память. Argon2 при хешировании заполняет большой объем памяти псевдослучайными данными, заставляя атакующего тратить ресурсы не только на процессоры, но и на память. GPU с их параллельностью и ASIC теряют эффективность, память не так-то просто распараллелить или сильно ускорить аппаратно. Argon2 стал стандартом де- факто для надежного хранения паролей: уже к 2019 году OWASP рекомендовала использовать Argon2id для всех новых систем вместо устаревших bcrypV'PBKDF2. На сегодняшний день у нас есть два класса хеш-алгоритмов криптографические хеши общего назначения (SHA-256, SHA-3, Blake2 и т.п.) и специализированные хеши для паролей (Argon2, а ранее bcrypt, scrypt). Почему их пришлось разде- лить — обсудим далее.
Односторонность, коллизии и эффект лавины Хорошую аналогию хеш-функции мы уже упомянули: мясорубка, перемалывающая данные в «фарш». Формально говоря, криптографическая хеш-функция должна быть односторонней. Это значит: зная выходное значение, практически невозмож- но найти входное. В математике под односторонней функцией подразумевается функция, которую легко вычислить, но чрезвычайно трудно обратить. В случае хеш-функции обратимость еще более затруднена тем, что множество входов значи- тельно больше множества выходов — разрядность хеша фиксирована и обычно на много меньше, чем размер возможных входных сообщений. Например, SHA-256 выдает 256 битов, то есть 2Л256 возможных значений. Входных же сообщений бес- конечно много (любые файлы любой длины). Поэтому разные входы ней збежно будут отображаться в один и тот же хеш (это следует из принципа Дирихле). Когда два разных сообщения дают одинаковую хеш-сумму, это называется коллизией. Раз коллизии теоретически неизбежны, почему мы говорим о невозможности под- бора? Дело в стойкости к коллизиям. Требование состоит в том, что найти пару разных сообщений с одинаковым хешем должно быть не проще, чем полный пере- бор Для идеальной хеш-функции длиной п битов это порядка 2Лп/2 операций (по парадоксу дней рождений). То есть для 128-битного хеша нужно порядка 2Л64 по- пыток, для 256-битного— 2Л128, что астрономически много. Это свойство извест- но как криптостойкость хеш-функции— вычислительно неосуществимо подоб- рать коллизию. Аналогично определяются свойства стойкости к первому прооб- разу (невозможно восстановить оригинал из хеша) и ко второму прообразу (не найдешь другое сообщение с тем же хешем, если дано какое-то сообщение). Фор- мально- для надежной хеш-функции с n-битным выводом вероятность случайного совпадения равна 2Л-п, и ничто лучше перебора не поможет. Это и есть односто- ронность: прямая функция легкая, обратная — нечеловечески тяжелая. Кроме стойкости к прообразам и коллизиям, есть и менее формальное, но важное свойство — эффект лавины. Малейшее изменение входных данных должно не- предсказуемо менять все биты хеша. Поменяли одну букву в файле — на выходе совсем другая последовательность, причем меняется подавляющее большинство битов. Это делает хеш чувствите льным ко всяким модификациям: нельзя «незамет- но» подправить сообщение — хеш всё выдаст. Более того, эффект лавины затруд- няет даже приближенный подбор входных данных под заданный хеш: изменение входа приводит к совершенно неожиданному результату, без какой-либо структуры. Чтобы представить эффект лавины, взглянем на и. иное грацию (рис. 6.3). В колонке слева показаны пять схожих текстов от короткого слова «Fox» до пред- ложений «The red fox jumps over the blue dog» с незначительными опечатками Справа— хеши (например, SHA-1) для каждого из них. Обратите внимание: хотя тексты отличаются лишь на один-два символа (где-то переставлены буквы в слове over), шестнадцатеричные хеши (блоки справа) совершенно несходны. Никакого очевидного сходства или системы — изменилось несколько битов во входе, а в вы- ходе хаотично поменялась добрая половина битов Лавина смела любую корреля- цию между похожими сообщениями Благодаря этому свойству мы уверены: если
два файла имеют одинаковый криптографический хеш, то файлы с невероятной вероятностью идентичны, ведь малейшее отличие привело бы к иному отпечатку. Конечно, теоретически возможна ситуация, что две разные картинки случайно дали один SHA-256, но вероятность этого исчезающе мала (примерно как шанс дважды подряд угадать выигрышный лотерейный билет). Input Digest Подведем итог. Надежная хеш-функция должна быть. (1) быстро вычислима. (2) необратима на практике (односторонняя), (3) устойчива к поиску коллизий лучше перебора. (4) демонстрировать эффект лавины. Плюс она детерминирована (одни и тс же данные всегда дают один и тот же хеш) и обычно равномерно распределя- ет выходы (чтобы не было Bias). Выполняя всё это, хеш-функция становится мощ- ным инструментом — своего рода цифровым отпечатком данных. В криптографии хеш широко применяется: для контроля целостности, для подписи сообщений (подписывают не весь документ, а его хеш), для проверки паролей, для построения алгоритмов выработки ключей. Действительно, без надежной хеш-функции не бы- ло бы ни современных цифровых подписей, ни эффективных протоколов обмена ключами. Зачем нужны разные хеш-функции? Казалось бы, раз хеш-функция так хороша, достаточно выбрать одну идеальную и использовать везде. Но на практике требования разнятся. Вернемся к нашим двум
задачам: проверке целостности файла и хранению пароля. Для целостности хочет- ся, чтобы хеш считался быстро, особенно на большом объеме данных. Представьте хранилище вроде Gil или торрент-клиент: им нужно пересчитывать хеши тысяч файлов, и это не должно занимать вечность. Классические алгоритмы (SHA-256, Biake2 и т. д.) как раз оптимизированы под скорость — прогоняют данные через серию операций, стараясь выжать максимум параллелизма и кеш-производи- тельности. Даже на слабом устройстве подсчитать SHA-256 от файла в несколько мегабайт — дело долей секунды. А что с паролями? Тут, наоборот, скорость как враг. Если хеш-функция позволяет мгновенно получить результат, злоумышленник может так же мгновенно переби- рать тысячи догадок в секунду. Допустим, база паролей хранит SHA-256 от каждо- го пароля. Злоумышленник узнал хеш и пытается подобрать исходный пароль. Он просто берет словарь популярных паролей (типаpassword] 23, qwerty' и др.), считает SHA-256 каждого слова и сравнивает с украденным хешем. Без специальной защи- ты это подчас тривиально: хеши паролей можно даже не перебирать самостоятель- но, их уже раскололи и выложили в радужных таблицах (таблицах соответствия хеша известным паролям). Например, утечка LinkedIn 2012 года показала, что хра- нить пароли в виде unsalted SHA-1 — очень плохая идея: 6,5 миллиона хешей прак- тически сразу были расшифрованы через базу готовых соответствий. Специализированные алгоритмы вроде Argon2 ставят целью сделать хеширование относительно медленным и ресурсоемким. ^Относительно» — го есть приемле- мым для одного вызова, но очень затратным для миллионов попыток. Argon2 по- зволяет настроить параметр времени. В идеале администратор выберет максималь- ные параметры, которые не слишком замедлят легитимный вход, но сделают мас- совый перебор невыгодным Представьте, что проверка одного пароля требует 50 миллиона операций и 100 Мбайт памяти, — даже на хорошей видеокарте не получится перебрать больше не- скольких сотен вариантов в секунду. А на CPU и того меньше Кроме того, Argon2 поддерживает параллелизм (можно задействовать несколько потоков), но это ско- рее для использования сервером — атакующему' от многопоточности ни холодно ни жарко, он всё равно сделает ту же работу, только распараллелив ее (то есть сум- марно не выиграет). Есть еще один нюанс: стойкость к атакам сторонних каналов. Классические хе- ши обычно нс пытаются скрыть паттерны доступа к памяти, потому что их сцена- рий— открытая среда (например, хеширование файла). Но при хранении пароля атакующий может попытаться выяснить, как именно вычисляется хеш. по косвен- ным утечкам (типа времени выполнения или дослупа к памяти). Одну из разновид- ностей Argon2 — Argon2i. специально делают memory-hard без зависимых от вход- ных данных операций, чтобы предотвратить утечки через кеш-тайминги. В общем, разные функции заточены под разные условия- одни — максимум производитель- ности на обычном железе, другие — максимальное сопротивление взлому паролей. Теперь, когда мы поняли, почему нам нужны различные хеш-алгоритмы, давайте рассмотрим их поближе и разберемся, как они устроены и применяются.
MD5 — эпоха надежд и коллизий MD5 можно сравнить с знаменитым, но опальным спортсменом. В молодости — чемпион, рекордсмен, к середине карьеры — первые скандалы, а ближе к выходу на пенсию — полное падение репутации. Алгоритм MD5 (Message Digest 5) был разработан Рональдом Ривестом и опубликован в 1992 году. Он генерирует 128- битный хеш. В 1990-х этого казалось более чем достаточно вероятность случайной коллизии 2Л-128 ничтожно мала MD5 быстро стат стандартом для всевозможных целей: контроль целостности файлов, проверка дубликатов, хранение хешей паро- лей в UNIX. Его предшественник MD4 имел слабости, поэтому MD5 позициониро- вался как исправленный и дополненный. Долгое время все было тихо, но, как мы знаем, криптоаналитики не дремали. В основе MD5 — хеш-структура Меркла-Дамгора: входное сообщение разбивается на блоки по 512 бит ов, каждый блок сминается неким нелинейным преобразовани- ем, итог предыдущего блока передается дальше, и в конце получается 128-битное состояние, выдаваемое как хеш. В MD5 предусмотрены 4 раунда по 16 шагов с раз- личными логическими операциями (AND, OR, XOR) и сдвигами. Всё это работает очень быстро на 32-битных машинах. Проблема в том, что у MD5 оказались скрытые симметрии Если подобрать специальным образом различаю- щиеся входы, они могут компенсировать изменения друг друга внутри раундов, и в итоге хеш получается одинаковым. В 2004-м был опубликован практический метод поиска коллизий для MD5. В 2005-м демонстрационно создали два разных сертификата Х.509 с одним МО5-хешем. Кульминация наступила в конце 2008 года группа исследователей (Маркус К. Якобссон, Сотиров, Стивенс и др.) на конференции 25СЗ показала атаку «порож- денные коллизии» и сумела подписать поддельный сертификат центра серти- фикации Проще говоря, они заставили уважаемый удостоверяющий центр выпус- тить сертификат на безобидный запрос, который имел такой же MD5, как и другой, злоумышленный запрос на поддельный СА-сертификат. В итоге удалось получить действительно подписанный действительный сертификат, которым можно было удостоверять любые сайты. Надежность всей инфраструктуры SSL оказалась под угрозой из-за устаревшего MD5. С тех пор MD5 официально признан криптографически сломанным. В 2012 году вирус Flame смог с помощью коллизии MD5 подделать цифровую подпись Microsoft и распространяться как «доверенное» обновление. Сегодня использовать MD5 для безопасности запрещено стандартами. Тем не менее он продолжает жить в качестве протокольной «заплатки» и для ненадежных проверок. Но каждая новая демонеipa- пия коллизий приближает окончательный закат MD5. Сейчас найти две разные стро- ки с одним MD5 можно за считаные секунды на обычном ПК, в сети даже есть гото- вые коллизии — например, два разных PDF-файла с одинаковым MD5. MD5 — исторически важный алгоритм, оказавшийся уязвимым к коллизиям. Его 128 битов хеша сегодня совершенно недостаточны. Применять MD5 можно лишь там, где атаки быть не может (например, для проверки загрузки, если хеш переда- ется по защищенному каналу). Для любых серьезных задач — ни в коем случае.
Но перед тем как попрощаться с MD5, посмотрим, как им вообще пользоваться. В стандартной библиотеке Go есть пакет crypto/md5, обеспечивающий вычисление MD5-xeuia. Пользоваться им просто (листинг 6.4). package main Import ( "crypto/md5" "encoding/hex" "fmt" ) func ma i n() { data := []byte("Hello, world!") // Наши входные данные hash := md5.Sum(data) Il Вычисляем MD5-xem //(тип [161byte) hashHex := hex.EncodeToString(hash[:]) Il Преобразуем байты /7 хеша з шестнадцатеричную строку f mt. Println ("№□:", nashHex) } Берем строку "Hello, world!" и считаем md5.Sum(). Функция Sum в пакете md5 воз- вращает массив из 16 байтов (128 битов), который затем перекодируем в привыч- ный формат hex для вывода. В реальных задачах вместо статической строки мы могли бы читать файл поблочно и писать в объект md5.New(), но суть от этого не ме- няется, библиотека позаботится обо всех деталях. Запустив программу, получим MD5-xeni Hello, world! равным 6cd3556deb(?da54bca0fe0i?4c39479ri39. В 2020-х MD5 годится разве что для проверок вроде уникальности объектов (и го лучше взять более стойкий SHA-256). В следующем разделе мы рассмотрим как раз SHA-256 — современный стандарт надежности, пришедший на смену и MD5, и SHA-1. SHA-256 — надежный наследник Алгоритмы семейства SHA-2 — это ответ криптосообщества на выявленные про- блемы предшественников. SHA расшифровывается как Secure Hash Algonihm. Конкретно SHA-256 выдает 256-битный хеш и основан на совершенно иной мате- матической основе, чем MD5. Его разработало Агентство национальной безопасно- сти США (NSA), но, вопреки теориям заговора, пока никаких тайных уязвимостей в нем не обнаружено — алгоритм открыт и изучен вдоль и поперек. SHA-256 рабо- тает по схеме Меркла Дамгора (поблочное сжатие), но длина блока 512 битов, а внутренних раундов — 64. Используются операции сложения по модулю 2Л32, по- битовые сдвиги и XOR., а также заранее определенные константы. Все эти детали
делают SHA-256 довольно шустрым на современных продессорах, а с появлением аппаратных инструкций (инструкции SHA-NI в новых Intel/AMD) — еще и исклю- чительно быстрым. В некоторых случаях SHA-256 с аппаратным ускорением обго- няет даже В1аке2, хотя традиционно считалось наоборот. В любом случае, скорость SHA-256 вполне достаточна для большинства применений, а главное — безопас- ность на высоте За 20 с лишним лет использования SHA-256 не претерпел ни одной серьезной криптоаналитической атаки. Были попытки немного снизить криптостойкость (ака- демические результаты, сокращающие перебор на несколько порядков), но ни од- ной коллизии или прсдобраза найдено не было. Это контрастирует с SHA-1, кото- рый к 2017-му был окончательно скомпрометирован. После атаки SHAttered прак- тики оперативно перевели инфраструктуру на SHA-256: браузеры перестали доверять сертификатам с SHA-1, Gil начал работу по переходу на SHA-256 для идентификации коммитов и ср. (впрочем, Git изначально не полагался на крипто- стойкость SHA-1, используя его как средство от случайных сбоев, но лучше пере- страховаться). Сейчас SHA-256 и родственный SHA-512— столпы хеширования. Многие криптографические библиотеки по умолчанию предлагают SHA-256. Про- токолы вроде TLS 1.2 и TLS 1.3 используют SHA-256 для формирования подписи сообщений и проверки целостности. Bitcoin — яркий пример системы, полностью зависящей от стойкости SHA-256: там хеширование используется повсеместно. Без устойчивости SHA-256 невозможна была бы и криптовалюта — злоумышленники могли бы штамповать блоки-подделки, находя коллизии хеша. Любопытная особенность SHA-256 — размер хеша 256 битов делает атаку дня ро- ждения (на коллизии), требующей около 2Л128 операций. Это настолько огромная цифра, что даже в энергию всего Солнца не укладывается. Однако классический совет криптографов— переходить на больший размер хеша, когда возможности компьютеров растут, остается в силе. Уже обсуждаются проекты перехода на SHA- 512/256 — это вариант SHA-512, укороченный до 256 битов, предоставляющий, по сути, 256-битную безопасность вместо 128-битной у SHA-256. Пока же прямых предпосылок для паники нет, SHA-256 считается безопасным и на ближайшее бу- дущее. Есть, правда, один нюанс: длина вывода. 256 битов — это хорошо для большинст- ва целей, но, например, для хранения паролей этого недостаточно. Дело не в криптостойкости, а в возможностях перебора. Если у пользователя пароль 8 симво- лов. то перебрать все варианты не такая уж запредельная задача, и хеш-функция тут не спасет (она же быстро считается). Поэтому, как мы говорили, нужно применять заторможенные функции (типа Argon2). Сам SHA-256 для паролей — решение посредственное’ оно надежно про- тив криптоаналитика, но не против банального перебора. В этом различие: SHA- 256 — как гоночный болид, он достигает своей цели (вычисления хеша) как можно быстрее. A Argon2 — как нагруженный грузовик: медленный, но мощный, перема- лывающий кучу ресурсов
С архитектурной точки зрения SHA-256 не слишком сложен; основная операция — это сжатие 512-битного блока сообщения в 256-битное состояние с помощью 64 раундов, каждый из которых перемешивает биты состояния (5 слов по 32 бита) с частями блока и добавляет константу. Происходит много побитовых сдвигов и XOR'ob — все они подобраны так, чтобы на выходе каждый бит зависел от как можно большего числа входных битов (эффект лавины). Алгоритм тщательно про- верялся на отсутствие каких-либо «упрощений»— если в MD5 криптоаналитики сумели воспользоваться линейными зависимостями, то в SHA-256 таких нет или они слишком сложны. Единственная слабость SHA-256, общая со всеми хешами на базе конструкции Дамгора, — это атака удлинения сообщения. Если вы вычисли- ли хеш SHA-256 для какого-то сообщения, то можно вычислить SHA-256 для этого же сообщения + дописанных данных без знания оригинала, используя внутреннее состояние. Поэтому нельзя делать наивный МАС вида SHA256(keyi 'message; — зло- умышленник может расширить сообщение и получить валидный МАС Правильное решение — либо использовать НМАС, либо алгоритм, не уязвимый к удлинению (например, функции семейства SHA-3 или Blake). Подытожим: SHA-256— надежная рабочая лошадка криптографии. Безопасный, относительно быстрый, с умеренным размером вывода. Он пережил свою моло- дость без серьезных изъянов, и хотя не так быстр, как некоторые новые алгоритмы, благодаря аппаратному ускорению остается в строю В пакете crypto/sha256 стандартной библиотеки реализован SHA-256. Его интерфейс похож на MD5 (листинг 6.5). import ( "crypto/sha256" "encoding/hex" "fmt" data := []byte("Helle, world!") hash := sha256.Sum256(data) // SHA-256 вычисляет 32-байтовый хеш fmt.Printin("SHA-256:", hex.EncodeTcString(hash[:])) Как видим, код почти не отличается, разве что функция другая и выходных байтов вдвое больше (32 байта против 16 у MD5). Если запустить его для той же строки "Hello, world!", получим: MD5: 6cd3556deb0aa54bca060b4c39479839 SHA-256: C0535e4be2b79ffd93291305436bf889314e4a3demos9b37f558dbe5c5e**... (Полный длинный SHA-256 выдавать тут нсг смысла — он содержит 64 символа hex.) Видно невооруженным глазом: разные алгоритмы — совершенно разные результа- ты, и длина SHA-256 хеша вдвое больше MD5. В остальном использовать их оди- наково просто. Пакет sha256 также предоставляет sha256.New() для прогрессивного
обновления хеша (чтобы, например, читать файл по кускам), но принципиальной разницы нет. На работе вы скорее всего будете использовать SHA-256 через какую-нибудь абст- ракцию (например, crypto/hmac для HMAC-SHA256 или через хелперы вроде ю.writestring (hash, data)). Важно помнить: SHA-256 настолько распространен, что многие системы ожидают именно его Например, JSON Wen Token (JWT) по умолчанию использует алгоритм HS256, что означает НМАС с SHA-256. Сертифи- каты Х.509 сейчас почти все подписаны SHA-256 (с RSA или ECDSA). Проверка целостности в Blockchain — опять же SHA-256. Так что умение работать с SHA- 256 — базовое требование для разработчика, и как видим. Go делает его тривиальным. ВЕаке2 — быстрее, сильнее, современнее Несмотря на успех SHA-2, криптографическое сообщество не успокоилось. Во- первых, была потребность в еще более быстрых функциях для программных реали- заций. Во-вторых, SHA-2— «родом из NSA», что вызывало некоторый скепсис (вспомним историю с DualECDRBG, где присутствовал бекдор от агентства, — люди начали подозревать любое вмешательство NSA). Поэтому конкурс SHA-3 привлек широкое внимание. Среди финалистов 2012 года была функция BLAKE, созданная коллективом под руководством Жана-Филиппа Омассона. BLAKE не выиграл (победил Кессак), но показал выдающиеся результаты по скорости и безо- пасности. На основе BLAKE в 2013 году создали В1аке2 — специально оптимизи- рованный для практического использования хеш-алгоритм, свободный от патентов и проще в интеграции, чем SHA-3. Чем же хорош В1аке2? Во-первых, он очень быстр. Заявлено, что В1аке2Ь (вариант для 64-битных систем) быстрее не только SHA-256, но даже небезызвестного MD5. В тестах В1аке2 часто опережает S11A-256 в 1,5-2 раза. Во-вторых, В1аке2 гибче, у него можно задать длину выходного хеша от 1 до 64 байтов, а также опдионатьно указать ключ, соль, персонализацию В1аке2 может работать как хеш и как МАС одновременно В-третьих, простота использования: нет сложных таблиц или ог- ромных констант. Внутри В1аке2 лежит модифицированное преобразование из шифра ChaCha, точнее, миксинг 16 слов через несколько раундов— G-функция. Конструкция В1аке2 — не классическая структура Меркл-Дам гора, a HAIFA, обес- печивающая защиту от удлинения сообщения без костылей. В1аке2 выпускается в двух основных вариантах: BIake2b— для 64-ра грядных платформ, выдает до 64 байтов (512 битов), и BlakeZs — для 8/'32-разрядных, выда- ет до 32 байтов (256 битов). По безопасности Blake2b эквивалентен SHA-3 (512- битная смесь — запас прочности О1ромный). ‘За —10 лет широкого анализа не най- дено уязвимостей, ставящих под сомнение стойкость В1аке2. Поэтому он стал по- пулярен. Например, в протоколе крипговатюты Monero B’ake2b используется для ключевых вычислений В Linux kernel добавлена поддержка Blake2s для хеширова- ния маленьких блоков данных (из-за высоких оптимизаций на малых обьемах). Библиотека libsodium предлагает Blake2b как рекомендованный кришо-хеш. А наш
следующий герой, Argon2, вообще встроил В1аке2Ь в свой сердечник — он исполь- зует В1аке2Ь для внутренних микропреобразований и финального хеша. Интересный вопрос: почему В1аке2, а не SHA-3? Ведь к 2015 году SHA-3 (Кессак) уже стандартизован. Причин несколько: SHA-3 значительно медленнее SHA-2 на обычных процессорах (он больше ориентирован на аппаратные реализации, bit- sliced дизайн). В1аке2 же по скорости сравним с SHA-1. Кроме того, SHA-3 — со- всем другой по структуре (на основе губки), и его внедрение требовало бы больше изменений в существующем софте. А В1акс2 легко вписывается, его можно исполь- зовать там же, где и SHA-256, получая выигрыш в производительности без значи- мых потерь (а то и с выгодой) в безопасности. Поэтому многие приняли В1аке2 с энтузиазмом. В некотором смысле В1аке2 стал народным SHA-3. хотя официально таковым не является. Стандартная библиотека Go (на момент подготовки книги) еще не включает В1аке2, НО есть внешняя библиотека golang.Org/x/crypto/blake2b (И blake2s). ЕЙ ЛеГКО ВОС- пользоваться (листинг 6.6). I - ---------------- ------------------------- — ------------------- Листинг 6.6 import ( "golang.org/x/crypto/bla ke2b" "encoding/hex" "fmt" ) data := []byte("Hello, world!") hashBytes := blake2b.S’im256 (data) // Blake2b с выходом // 256 битов (32 байта) fmt.Printin("Blakezb-256:", hex.EncodeTcString(hashBytes[:])) Функция biaKe2b.Snm256 сразу возвращает массив из 32 байтов. Есть также Swn5i2 для 64 байтов Если хотим нестандартную длину или использовать ключ (например, для НМАС-подобного случая), можно создать хеш через blake2b.New (size, кеу) — при ненулевом key хеш автоматически превращается в Blake2 МАС. Как видим, API напоминает привычный sha256/md5, разница только в импортируемом пакете. Выполнив код, получим Blake2b-256 хеш для "Hello, world!", равный 256ccafaa8... (опустим полную строчку). Конечно, визуально хеши В1аке2 не «узнать»— они выглядят как случайные, что, собственно, и требуется. Но можно проверить, что длина строки 64 символа hex, то есть 32 байта, как и ожидалось. Стоит упомянуть, что пакет x/crypto/blake2b — это официальная реализация от команды Go, прост о не лопавшая в основную поставку. Возможно, в будущих вер- сиях Go Blake2 войдет в crypto-пакеты, учитывая его популярность. Но даже если нет — подключить внешний модуль в Go весьма просто. Где применим В1аке2? Везде, где нужна скорость и криптостойкость. Например, вы вычисляете хеш пароля для проверки целостности (не хранения! а именно, ска-
жем. чтобы быстро сравнить введенный пароль с ранее захешированным при ау- тентификации по сети) — В1аке2 будет отличным выбором. Или вам нужно индек- сировать огромное количество файлов по содержимому — В1аке2 опять поможет, сократив время на хеширование. Единственное ограничение: В1аке2 — сравнитель- но новый алгоритм, и не во всех стандартах он присутствует. Поэтому, если вы пишете что-то, что должно соответствовать ГОСТам или NIST, проверяйте совмес- тимость. Для вну греннего же использования В1аке2 совершенно адекватен. В 2020 году на смену В1аке2 авторы предложили В)акеЗ. еще более быстрый и поддерживающий параллельное вычисление на нескольких ядрах (с деревообраз- ной схемой). Но В1акеЗ — это уже отдельная история. Достаточно знать, что разви- тие идет дальше, хотя В1акс2 еще долго будет более чем достаточен по прочности запаса. Агдоп2 — хеширование паролей на стероидах Наконец, добрались до Argon2. дтгоритма. специально созданного для защиты па- ролей. Его название происходит от инертного газа аргона— намек на «тяжелый и тормозящий» характер алгоритма. Как мы уже обсуждали, главная опасность при хранении паролей— быстрое перебирание вариантов злоумышленником. Класси- ческие алгоритмы хеширования паролей, такие как bcrypt и scrypt, уже использова- ли идею замедления. Bcrypt просто многократно шифрует пароль, a scrypt, помимо CPL1. нагружает память (для каждого пароля вычисляется и хранится большой век- тор, который потом случайно индексируется). Argon2 развил идеи scrypt и довел их до логического предела, попутно устранив недостатки предшественников. Argon2 выиграл конкурс РНС в 2015-м, предложив три варианта алгоритма: Argon?d, Argon2i и Argon2id. Разница между ними в том. как они обращаются к памяти: Argon2d (d = data dependent) делает доступы, зависящие от содержимого ранее заполненной памяти, — это дает максимальную сложность для атакующего (невозможно заранее спрогнозировать, к каким адресам нужно готовить быстрый доступ), но делает алгоритм уязвимым к атакам по сторонним канатам (злоумыш- ленник может, например, анализировать паттерны обращения к памяти, если имеет доступ). Argon2i (i = independent), наоборот, — использует доступ к памяти, не за- висящий от содержимого (например, псевдослучайную последовательность, не за- висящую от пароля), что защищает от утечек через кеш, но несколько упрощает жизнь атакующему с точки зрения оптимизации (требуется больше проходов для стойкости). Argon2id — гибридный подход: первые проходы выполняются как Argon2i, а последующие — как Argon2d. Это призвано закрыть оба вектора: и side- channel атаки, и сокращения памяти через оптимизации. В общем, если сомневае- тесь — выбирайте Argon2id Рассмотрим, как работает Argon2, При хешировании пароля задаются параметры: timeCosr (число итераций, т. е. насколько долго гонять алгоритм), memoryCost (сколько памяти в килобайтах/мегабайт ах выделять) и parallelism (число потоков). Плюс, конечно, входные данные: сам пароль и соль (уникальная случайная строка на пользователя, без которой вообще нельзя хранить пароли, она предотвращает
атаки по заранее вычисленным таблицам). Argon2 сначала расширяет пароль и соль до некой базовой псевдослучайной последовательности (с помощью внутренней функции на основе Blake2b). Затем резервирует указанное количество памяти и за- полняет ее блоками в несколько проходов Каждый новый блок вычисляется с ис- пользованием ранее полученных блоков (по определенной схеме — в Argon2d ад- рес берется из содержимого предыдущего блока, в Argon2i адрес берется из незави- симого PRNG). В результате память заполняется, псрезаполняется. и в конце из всей этой громады данных вычисляется итоговый хеш (опять же Blakc2b на всё со- держимое). Вся суть — заставить противника либо хранить все эти блоки (что тре- бует столько же памяти, иначе не воспроизвести результат), либо терпеть огромные штрафы, пересчитывая их каждый раз. Атака с урезанием памяти (lime-memory irade-off) становится невыгодной: сэкономив память, платишь многократным рос- том времени. Авторы Argon2 провели много анализа, чтобы подобрать такую схему заполнения, которую нельзя сильно упростить умным математическим трюком. Пока что Argon2 держится молодцом, известных уязвимостей нет (были мелкие улучшения между версиями 1.0 и 1.3, но это скорее шлифовка). Одним словом. Argon2 — это «хеш-функция, которая должна быть медленной». В Go Argon2 реализован в пакете golang.org/x/cnpto/argon2. Там есть две функ- ции: агдоп2.кеу— для Argon2i и агдоп2.тэкеу— для Argon2id. Мы будем использо- вать Argon2id (как наиболее рекомендуемый) (листинг 6.7). Листингб.7 import ( "golang.org/х/crypto/argon2" "crypto/rand" "encoding/base 64" "fmt" ) func main() { password := Hbyte ("P@sswCrdl23") // то, что вводит пользователь salt := make( []byte, 16) err := rar.d.Read (salt) // сгенерируем криптографически // случайную сель 16 байтов if err != nil { pan±c(err) } // Параметры Argon2id (в реальней жизни брать // из настроек безопасности) timeCost = uint32(3) // число итерации memoryCcst = uint32(32*1024) // 32 MB памяти threads = uint8(4) // параллелизм (число потоков) keyLer. = uint32(32) // длина выходного хеша // 32 байта (256 битов)
hash •= агдэп2.IDKey(password, salt, timeCost, memoryCost, threads, keyLen) // Закодируем результат для хранения (обычно // хранят вместе: параметрытсоль+хеш) encoded := oase64.RawStdErcoaing.EncodeTcString(hash) fmt.PrintIn("Argon2id hash (oase64):", encoded) ) Рассмотрим этот код. Мы подготовили пароль и сгенерировали 16-байтную соль. Никогда не используйте фиксированную или предсказуемую соль — она должна быть случайной для каждого пароля. Далее задаем параметры- в нашем примере 3 итерации, 32 Мбайт памяти, 4 параллельных потока и длина ключа 32 байта. Эти параметры выбраны для иллюстрации — на практике нужно подбирать их экспе- риментально под вашу систему, чтобы хеширование занимало, скажем, около 0,5 секунды: чем мощнее железо, тем выше можно выставить. Функция argonz.IDKey возвращает срез байтов длиной keyLen, Мы преобразовали его в base64 строку для удобства хранения. Как хранить результат? Принято сохранять не только хеш. но и соль, и парамет- ры — чтобы при проверке пароля мы знали, с какими параметрами был получен хеш. Формируется строка наподобие следующей: $argon2id$v=19$m=32768,t=3,p=4$<base64(salt)>$<base64(hash)>. Смысл в том. что если в будущем потребуется повысить параметры (скажем, до 64 Мбайт памяти), мы сможем это сделать для новых паролей, а старые проверить по старым настройкам, Если теперь пользователь вводит пароль повторно, для проверки мы возьмем со- храненные соль и параметры, снова вызовем argon2.IDKey и сравним полученный байт-срез с хранимым. Благодаря соли совпадение возможно только при правиль- ном пароле. А благодаря сложности Argon2 подобрать пароль перебором крайне сложно: каждый проверяемый вариант потребует вычеркнуть те же .32 Мбайт * * 3 итерации * 4 потока работы. Как говорится, сытный обед для CPU. Конечно, надо помнить про общую гигиену: пароли все равно должны быть доста- точно длинными и случайными, иначе перебор словаря возможен даже с Argon2 (если у пользователя пароль 123456, никакой Argon2 не спасет, атакующий просто проверит популярные варианты). Выбор хеш-функции ♦ Для проверки целостности и общих задач — выбирайте SHA-256 или Biake2. Если вам нужна совместимость со всеми сторонними системами и стандарта- ми — SHA-256 (или вообще семейство SHA-2, включая SHA-512/224 и др.) — беспроигрышный вариант. Он по-прежнему «золотой стандарт». Если же вы пишете свое приложение и хотите выжать максимум производительности, смело берите Blake2b Он прост в использовании, быстрее и так же надежен.
Доказательством надежности Blake2 служит хотя бы то. что сам Argon2 — крайне параноидальный в плане безопасности проект — доверил Blake2b внут- ренние расчеты. ♦ Не используйте устаревшие MD5 и SHA-1 для чего-либо критичного. Эти алгоритмы дешево взломать на коллизии. Если вам кто-то отдает систему хешей на SHA-1 — планируйте миграцию на SHA-256. ♦ Для криптографических протоколов и подписей: — используйте SHA-256/SHA-3 или Blake2, а при потребности — более длинные SHA-512. В цифровых подписях. НМ АС, RSA-сертификатах— везде сегодня доминирует SHA-256. Он достаточно хорош. В новых стандартах могут добавить поддержку SHA-3 (например. JSON Web Signature позволяет SHА3-256), но пока это ред- кость. Blake2 постепенно входит в стандарты (например, есть RFC 7693 для Blake2). Учтите, что .для НМАС (ключевой хеш) нельзя напрямую использовать простой хеш с ключом, кроме Blake2. Если требуется, применяйте crypto/hmac — он «под капотом» правильно делает НМАС для SHA-256 или другого алгоритма. Кстати, Blake2 ключ принимать умеет сам, так что НМАС ему не нужен, доста- точно указать key. ♦ Для хранения паролей: только специализированные схемы (Argon2, scrypt, bcrypt). Никаких SHA-256 без соли! Никаких «два раза SHA-1»- это не поможет. Па- роль — слишком уязвимая вещь, чтобы полагаться на быстродейственные хеши. Argon 2id — лучший выбор на 2026 год. Если он недоступен, используйте bcrypt или scrypt. В Go есть пакет golang.org/x/crypto/bcrypt для bcrypt (но он ограни- чен паролями <72 байта и у него меньше гибкости). Scrypt тоже есть (x/crypto/scrypt), но настройка сложнее, и scrypt несколько устарел по сравне- нию с Argon2. Самое главное обязательно соль для каждого пароля и доста- точная «работа» (work factor). Современные рекомендации: Argon2id с памятью хотя бы 32 Мбайт и временем ~0.1 секунды на хеш. Это сделает атаку очень на- кладной. ♦ Размер хеша и формат хранения — для целостности и идентификаторов обычно берут 256 битов (32 байта)— этого хватает с большим запасом (вероятность случайного совпадения 10z -77). Длиннее брать имеет смысл только для специфичных случаев (например, хеш- адрес в блокчейне— двойной SHA-256, что эффективнее 256 битов, или для особо параноидальных — SHA-512/256). Для паролей пет смысла хранить более 128-256 битов, потому что стойкость все равно определяется сложностью пере- бора, а не длиной хеша. Но поскольку Argon2 и так быстродействием не обреме- нен. часто просто сохраняют 256 битов, чтобы было «как у всех». ♦ Проверка загрузок и файлов— сегодня на сайтах вы скорее увидите SHA-256 или SHA-512 контрольные суммы дистрибутивов, чем старые CRC32/MD5.
Это правильно — если злоумышленник заменит файл, он не сможет подделать SHA-256 без неимоверных ресурсов. Для MD5 же он, теоретически, мог бы по- добрать коллизионный файл Поэтому, если вы заботитесь о пользователях, пуб- ликуйте SHA-256 для скачиваний. Ну и сами в коде используйте крипто-хеши вместо простых, даже для внутренних проверок, — вдруг когда-нибудь внут- ренний сервис станет внешним. Хеш-функпии — фундаментальный кирпичик криптосистем. Без них не построить ни цифровую подпись, ни безопасный протокол обмена ключами. В следующей главе мы как раз перейдем к цифровым подписям и протоколам, где увидим хеш- функции в действии в составе более сложных конструкций.
- Глава 7- Реализация цифровых подписей и протоколов безопасности В предыдущей главе мы рассмотрели хеш-функции — алгоритмы, превращающие данные любого размера в компактный цифровой отпечаток. SHA-256 генерирует уникальный хеш, Argon2 защищает пароли от перебора, В1аке2 балансирует между скоростью и стойкостью. Но хеш решает только половину головоломки. Он отвеча- ет на вопрос «Изменилось ли сообщение?», но молчит на другой: «Кто его автор?». Представьте: Дмитрий получает по почте контракт якобы от Светланы Он прове- ряет хеш документа — да, совпадает с тем, что указан в письме. Отлично, файл не повредился при передаче. Но откуда знать, что контракт действительно от Светла- ны? Может, это Владимир перехватил переписку и подсунул свой документ с кор- ректным хешем? Хеш-функция гарантирует целостность, по не подлинность отпра- вителя. Вот здесь и возникает очень большая проблема современной криптографии. Мы уже узнали в предыдущих главах, как работает асимметричное шифрование, — через пару ключей, где открытый шифрует, а закрытый расшифровывает. Но для доказательства авторства нужна обратная конструкция: что-то, зашифрованное за- крытым ключом, может расшифровать любой с помощью открытого ключа. Звучит бесполезно для конфиденциальности, но революционно для аутентификации. Это и есть цифровая подпись— криптографический механизм, который одновременно доказывает и авторство, и неизменность данных. Математическая сущность цифровых подписей Базовая схема любого алгоритма цифровой подписи состоит из трех компонентов: ♦ Генерапия ключей (KeyGen). .Алгоритм, который на основе случайных данных создает пару (sk, pk), где sk — секретный ключ подписи (signing key), pk — открытый ключ верификации (venfication key) В отличие от шифрования, здесь секретный ключ используется для создания подписи, а открытый — для проверки.
♦ Подписание (Sign) Алгоритм, который принимает сообщение m и секретный ключ sk и производит подпись о. Подпись должна зависеть и от сообщения, и от ключа таким образом, чтобы без знания sk создать валидную подпись для m вычислительно было не- возможно ♦ Верификация (Verity). Алгоритм, который принимает сообщение ш, подпись о и открытый ключ рк и возвращает true/false. Если подпись создана правильным секретным ключом для данного сообщения — верификация проходит, иначе — нет, Математически это выражается через односторонние функции с лазейкой. Для RSA-подписи, например, сообщение m (точнее, его хеш Н(т)) возводится в степень d по модулю N: о - H(m)Ad mod N. Здесь d— секретная экспонента (часть закры- того ключа), N — большое составное число. Проверка: вычисляется Н' = оЛе mod N (где е — открытая экспонента), и результат сравнивается с Н(т). Если равны — подпись валидна Зная факторизации N, невозможно вычислить d из е и N. Попытка подобрать о для произвольного пт эквивалентна вычислению корня е-й степени по модулю N, что является вычислительно сложной задачей. Но здесь кроется тонкий момент. Простое возведение в степень само по себе уяз- вимо к атакам. Если просто подписать два сообщения mi и пь, злоумышленник мо- жет создать подпись для произведения тгпн без знания секретного ключа, просто перемножив подписи: <ji 02 = (miAd)-(ni2Ad) = (mrni2)Ad mod N. Это называется мультипликативной атакой. В RSA используются схемы вроде PSS, которая добавляет случайность и специаль- ную структуру к сообщению перед подписанием. PSS делает каждую подпись уни- кальной даже для одинаковых сообщений за счет случайной соли и математически доказуемо стойка к известным атакам. Схемы подписей на эллиптических кривых RSA-подписи работают, но громоздки. Для 2048-битного RSA подпись занимает 256 байтов. Эллиптические кривые позволяют достичь той же безопасности с под- писью всего в 64 байта (для 256-битной кривой). Рассмотрим три основные схемы. ♦ ECDSA — прямой аналог DSA на эллиптических кривых. .Алгоритм подписания выглядит так: 1. Вычислить е = Н(тп) (хеш сообщения). 2. Выбрать случайное к из [1, п-1], где п — порядок группы. 3. Вычислить точку R = k G (G — генератор группы). 4. Взять г = R х mod п (х-координату точки R). 5. Вычислить s = k-1(e + rd) mod п, где d — секретный ключ.
6. Подпись — пара (г, s). Проверка требует вычисления двух точек и сравнения результата с г. Уязвимость ECDSA — в необходимости уникального к для каждой подписи Если к повторится или станет предсказуемым, секретный ключ легко вычис- ляется. Кстати, это произошло с PlayStation 3 в 2010 году. Sony использовала кон- стантный к, что позволило хакерам failOverflow вычислить закрытый ключ компании за несколько часов. Проблема предсказуемого к настолько серьезна, что привела к появлению де- терминированного ECDSA (RFC 6979), Вместо случайного к используется детерминированное значение, вычисленное через НМАС от секретного клю- ча и хеша сообщения: k = HMAC-SHA256(d, H(m)). Это гарантирует уни- кальность к для разных сообщений, но воспроизводимость для одного и того же сообщения, что в целом иногда полезно для отладки, ♦ EdDSA (Edwards curve Digital Signature Algorithm) — современная альтерна- тива, разработанная Даниэлем Бернстайном в 2011 году. Используется на кри- вых в форме Эдвардса, самая популярная реализация— Ed25519 (кривая Curve25519). EdDSA по дизайну детерминирован — к вычисляется через хеш секретного ключа и сообщения, проблема повторного использования к невозможна. .Алго- ритм устойчив к timing-атакам — все операции выполняются за постоянное вре- мя независимо от данных. Подписи компакгны (64 байта) и быстро проверяют- ся. Нет необходимости в дополнительной проверке правильности входных дан- ных, невалидные точки автоматически отклоняются. Схема подписания Ed25519: 1. Секретный ключ sk — 32 байта случайных данных. 2. Вычислить хеш H(sk) = (a, prefix), где а — скалярный ключ, prefix — префикс. 3. Открытый ключ pk = a G (точка на кривой). 4. Для подписи сообщения т: • г = H(prefix I т) mod п; • R = rG; • S = (г + H(R I pk I т) а) mod n. 5. Подпись — (R, S). Проверка. S-G = R + H(R I pk I m) pk. Если равенство выполняется, то подпись валидна. Ed25519 сейчас — стандарт для SSH, TLS 1.3 (как опция), протоколов Signal, Tor, WireGaard В криптовалютах вроде Cardano и Polkadot тоже Ed25519. Причина проста — это самый быстрый и безопасный алгоритм подписи обще- го назначения.
Реализация Ed25519 в Go Go поддерживает Е625519 в пакете crypto/ed255i9, который появился в версии 1.13 (листинг 7.1). package main import ( "crypto/ed2 5519" "crypto/rand" "fmt" func main() ( // Генерация ключей (возвращает (32]byte seed, // но можно использовать напрямую) publicKey, privateKey, : = ed25519.GenerateKey(rand.Reader) message : = []byte("Перевод IOC BIC на адрес iAlzPleP...") // подписание, одна функция, без явного хеширования signature := еа25519.Sign(privateKey, message) fmt.Printf("Подпись (%d байт) : %x\n", len (signature), signature) 11 проверка valid := ed25519.Verify(publicKey, message, signature) fmt.Pr: ntf("Подпись валидна: av\n", valid) // попытка подделки message[0] Л= OxFF valid = ed25519.Verify(publicKey, message, signature) fmt .Printf("После изменения сообщения: %v\n", valid) ) Нет явного хеширования, нет выбора параметров паддинга, все инкапсулировано. Функция sign принимает приватный ключ (64 байта: 32 байта seed + 32 байта от- крытого ключа) и сообщение произвольной длины и возвращает подпись 64 байта. Внутри она делает всё необходимое: хеширование SHA-512, вычисление г и S, сборку подписи. Ed25519 не требует предварительного хеширования сообщения пользователем.
- Глава 8- Атаки на цифровые подписи Теоретическая стойкость алгоритмов — одно, реальная безопасность систем — другое. Рассмо1рим некоторые атаки, которые эксплуатируют не слабость матема- тики, а ошибки реализации и использования. Replay-атаки и временные метки Простейшая атака — replay (воспроизведение). Владимир перехватывает валидную подписанную транзакцию Дмитрия «перевести 1000$ Светлане» и отправляет ее на сервер повторно. Подпись валидна, сервер выполняет перевод второй раз. Дмитрий теряет еще 1000$. Защита— включение в подписываемые данные уникальной информации: времен- ной метки (timestamp), порядкового номера (попсе), идентификатора сессии. Сервер отслеживает использованные попсе и отклоняет повторы Например- message = "transfer 1000$ to Alice | timestamp: 1704067200 | nonce: 7a3f2e9c" signature = Signfsk, message) При проверке сервер проверяет: не истск ли timestamp (допустим, ±5 минут), не использован ли попсе ранее. Если хоть одно условие не выполнено — отклоняем, даже если подпись математически корректна. Проблема временных меток — рассинхронизация часов между клиентом и серве- ром. Если часы клиента отстают на час, его запросы отклоняются. Решение — протоколы синхронизации времени (NTP) или использование сервер- ного времени (сервер при первом подключении отправляет клиенту свое текущее время, клиент корректирует свое относительно него). Подмена контекста (context substitution) Более изощренная атака эксплуатирует отсутствие привязки подписи к контексту' использования. Представим; Дмитрий применяет один приватный ключ для двух систем — подписи документов и подписи транзакций. Владимир берет подпись под документом «Я согласен с условиями договора» и пытается использовать ее как подпись транзакции.
Если обе системы просто проверяют verify (pk, message, signature) без учета кон- текста — атака успешна. Зашита — включение идентификатора домена (domain separation) в подписываемые данные: document_iressage = "D0CUMEN7_vl I " + document-text transaction_message = "TRANSACTION_v2 | " + transaction_details Теперь подпись под документом никогда не будет валидна для транзакции, потому что префиксы разные. Ed25519 в некоторых реализациях принимает необязатель- ный параметр context — строку до 255 байтов, кот орая автоматически подмешива- ется в хеш при подписании и проверке. Слепые подписи и их применение Слепая подпись (blind signature) — криптографический протокол, где подписант подписывает сообщение, не видя его содержимого. Изобретена Дэвидом Чомом в 1982 году изначально для анонимных электронных денег. Классическая схема слепой RSA-подписи: 1. Дмитрий хочет, чтобы банк подписал сообщение, т, но банк не должен знать т. 2. Дмитрий выбирает случайный blinding factor г. 3. Вычисляет замаскированное сообщение m' = m • гЛе mod N (где е, N — откры- тый ключ банка). 4. Отправляет т' банку. 5. Банк подписывает: o' = (m')Ad mod N = mAd r mod N. 6. Дмитрий снимает маску: о = o’ / г = m dmodN. 7. Теперь у Дмитрия есть валидная по,длись банка под т, но банк никогда не видел т. Применение — анонимные платежные системы. Банк выдает слепые подписи на электронные токены фиксированного номинала. Пользователь может потратить токен анонимно — получатель проверяет подпись банка, банк принимает токен к погашению, но не может связать выдачу и погашение (потому что токен был замас- кирован при выдаче). Другое применение — анонимное голосование. Избиратель получает слепую под- пись избирательной комиссии на свой бюллетень, затем подает его через аноним- ный канал. Комиссия проверяет подпись— значит, голос от зарегистрированного избирателя, но не может узнать, от кого конкретно. Слепые подписи не поддерживаются стандартной библиотекой Go напрямую, но реализуются через пакеты вроде golang.org/x/crypto/openpgp (частично) или сто- ронние библиотеки. Пороговые подписи Пороговая подпись (threshold signature) — схема, где секретный ключ распределен между несколькими участниками, и для создания подписи требуется сотрудничест-
во минимального числа из них. Например, схема 2-из-З: трое держателей секретно- го ключа, любые двое могут создать подпись, один не может. Математика строится на схеме разделения секрета Шамира. Секретный ключ d представляется как полином степени t-1: Р(х) = d + агх + аг-х2 + ... + а^гх'1, где коэффициенты а1 — случайные. Каждому участнику выдается доля: d, = P(i). Лю- бые t участников могут восстановить Р(0) = d через интерполяцию Лагранжа, меньше t — не могут. Для создания пороговой подписи участники не восстанавливают d явно (это было бы небезопасно — один из участников мог бы украсть полный ключ). Вместо этого они создают частичные подписи со своими долями и комбинируют их в финальную подпись математически, без раскрытия долей. Применение— мультиподпись в криптовалюэах, где средства защищены схемой 2-из-З или З-из-5 Также используется в корпоративных системах для каких-либо важных операций — скажем, перевод крупной суммы требует подписи любых трех из пяти топ-менеджеров. В Go есть экспериментальная поддержка через пакеты вроде github.com/combase/kryptology или github.com/taurusgroup/frost-ed25519 (реализация пороговых Ed25519-подписей по протоколу FROST). В1_8‘Подписи и агрегация BLS (Boneh-Lynn-Shacham) — схема подписи на парных кривых (painng-based cryptography), изобретенная в 2001 году. Главная фишка в агрегируемости: множе- ство подписей от разных ключей на разные сообщения можно объединить в одну компактную подпись. Представьте: 1000 валидаторов в блокчейне должны подписать блок. С ECDSA нужно хранить 1000 подписей по 64 байта = 64 Кбайт. С BLS все 1000 подписей сливаются в одну подпись -48 бай гов (для BLS12-381). Экономия приличная. Агрегация работает так: если есть подписи oi, 02, .., оп от ключей pki, ркг,..., ркп на сообщения mi, rm, . .., md, то агрегированная подпись — просто сумма точек на кри- вой: л_ agg = <Ti + az + ... + Проверка одна: e(o_agg, G) = e(H(rm), pki) • e(H(mz), ркг) -... • e(H(mn), ркп), где е — билинейное спаривание. BLS используется в Ethereum 2.0 (каждый слот подписывают сотки валидаторов), в Chia, Dfinity. Минус только в том, что всё это требует операций pairing, которые медленнее обычных операций на кривых (проверка BLS ~ в 10 раз медленнее Е625519). Плюс — агрегация окупает это в сценариях с множественными подписями. Go не имеет встроенной поддержки BLS, но есть библиотеки вроде github.com/herumi/bls-eth-go-binary (обертка над высокопроизводительной биб- лиотекой С+т) или girhub.com/draud/kyber (чистый Go, но медленнее).
JWT и stateless-аутентификация Хорошо, вроде что-то про цифровые подписи понятно. Но где всё это применяется в жизни? Один из самых массовых сценариев — аутентификация пользователей в веб-приложениях. Традиционная схема веб-аутентификации выглядела так: Дмитрий вводит логин и пароль, сервер проверяет их в базе данных. Если всё верно — сервер создает сес- сию: генерирует случайный идентификатор (session ID), сохраняет его в Redis/Memcached/базе вместе с информацией о пользователе (user id, роли, время истечения), отправляет session ID клиенту в coolie. При каждом последующем за- просе браузер автоматически отправтяет этот cookie, сервер находит сессию по ID в хранилище, читает данные пользователя, всё работает. Проблема начинается при масштабировании. Представьте: у нас 100 серверов за балансировщиком назрузки. Дмитрий залоги- нился, попал на сервер № 42, там создалась сессия. Следующий запрос попал на сервер № 73, а гам этой сессии нет. Нужно либо sticky sessions, либо централизо- ванное хранилище сессий. Еще проблема в микросервисной архитектуре У нас 20 разных сервисов (API про- филя. API платежей, API уведомлений...). Каждый должен проверять аутентифика- цию пользователя. Варианты: либо каждый сервис ходит в централизованную базу сессий (опять задержки), либо делаем отдельный auth-сервис, к которому обраща- ются все остальные (еще один сетевой хоп, single point of failure). Идея: зашифровать сессию в токене А что если нс хранить сессию на сервере вообще? Вместо этого упаковать всю не- обходимую информацию прямо в cookie, которое отправляется клиенту. Например: uset_id=l2345&role=adnu nsexp.i res=l 704153600 Проблема очевидна: Дмитрий может открыть браузерные devtools и отредактиро- вать cookie, поменяв role=user на role=admin. Нам нужна защита от подделки. Можно зашифровать cookie симметричным ключом — но тогда каждый сервис должен знать этот ключ для расшифровки (опять расшаривание секретив). А главное— шифрова- ние не дает аутентификации: если cookie украден, его можно использовать. Правильное решение: не шифрование, а цифровая подпись. Сервер формирует JSON с данными пользователя и подписывает его своим приватным ключом. От- правляет клиенту и данные, и подпись. Клиент хранит это как есть (данные не за- шифрованы, можно прочитать — но это не сграшно, там нет секретов). При запросе к .побому сервису клиент огправляег токен. Сервис проверяет подпись открытым ключом— если валидна, значит, токен действительно выдан auth-сервисом и не изменен. Никаких походов в базу, никакой централ]>ной сессии, всё stateless. Это и есть идея JWT (JSON Web Token).
Анатомия JWT: три части JWT имеет структуру header.payload.signature, где каждая часть— это Base64URL- кодированные данные, разделенные точками. ♦ HEADER — метаинформания о токене: { "alg": "ES256", "гур”: "JWT" } alg — алгоритм подписи (ES256 = ECDSA с кривой Р-256 и SHA-256, RS256 = RSA с SHA-256, HS256 = HMAC-SHA256). typ — тип токена (всегда "jwt"). ♦ PAYLOAD — полезные данные (claims): { "sub": "use: 12345", "name": "Дмитрий", "role": "admin", "iat": 1704067200, "exp": 1704153600 } sub (subject) — идентификатор пользователя, iat (issued at) — время выдачи (Unix timestamp), exp (expiration) — время истечения name, role— произвольные данные приложения. payload не зашифрован Это просто Base64— любой может декодировать и прочитать. Поэтому в JWT нельзя класть пароли, токены доступа к API. номера кредиток. Только информацию, которою можно показать клиенту. ♦ SIGNATURE — подпись первых двух частей: signature = Sign(privateKey, SHA256(base64url(header) + + base64url(payload))) Для ES256 это ECDSA-подпись (64 байта), для RS256 — RSA-подпись (256 бай- тов для 2048-битного ключа). Итоговый токен выглядит примерно так: eyJhbGciOiJFUzIlNiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlc2VyXzEyMzQHiwibmFtZSI6ItCUOLzQuNGCO YDQuNC5Iiwicm9sZSI6ImFkbWluIiwiaWFOIjoxNzAOMDY3MjAwLCJleHAiOjE3MDQxNTM2MDB9.MEUCIQDEx. Жизненный цикл JWT ♦ Выдача токена — логин (листинг 8.1). ЯИСТИНСЦ! II Пользователь отправил легин/пароль func login(username, password string) (string, error) { // Проверяем в базе user := cnecxCreder.tials (username, password)
if user == nil { return errors.NewCinvalid credentials") } // Создаем claims claims := jwt.MapClaims{ "sub": user.13, "name " : user. Nani?, "role": user.Role, "lat": time.Nowl) .Unix(), "exp": time.NowO .Add(24 * time .Hour) .Unix (), ) // Создаем токен tOKen := jwt.NewWithClaims(;wt.SigningMethodES256, claims) // Подписываем приватным ключом сервера tokenstring, err := token.SignedString(privateKey) return tokenstring, err Сервер возвращает токен клиенту (обычно в JSOM: {"access token": "eyj,.."}). Клиент сохраняет cookie в localstorage или httpCnly. ♦ Используем токен — выполняем защищенные запросы (листинг 8.2). Листинг 8-2 func protectedEndpoint(w http.F.esponseWriter, r *http.Request) { // Достаем токен из заголовка Authorization: Bearer <token> authHeader := r.Header.Get("Authorization") tokenstring := strings.TrimPrefix(authHeader, "Bearer ") // Парсим и проверяем подпись token, err := jwt.Parse(tokenstring, func(token *jwt.Token) (interface{}, error) { // Проверяем алгоритм if _, ok := token.Method. (’'jwt.Signir.gMethudECDSA); !ok { return nil, fmt.Errorf("unexpected method: %v", token.Header["alg"]) } return publicKey, nil }) if err != nil I I (token.Valid { http.Error(w, "Unauthorized", 4C1) return
li Извлекаем claims claims := token.Claims.(jwt.MapClaims) userID := claims ["sub"] . (string) role := claims["role").(string) // Проверяем срок действия (библиотека делает автоматически, // нс можно и вручную) exp := int64(claims["exp"].(float64)) if time.Now() .Unix() > exp { http.Error(w, "Token expired", 4C1) ret urn // Все ок, обрабатываем запрос fmt.Fprintf(w, "Hello, user %s with role %s", userID, role) Пронерка подписи не требует обращения к базе данных Сервис просто проверяет математическую корректность подписи открытым ключом. Если подпись валид- на — значит, токен выдан доверенным aulh-сервисом, и данные не изменены. Мож- но сразу доверять claims внутри. Симметричная и асимметричная подпись JWT поддерживает два класса алгоритмов. ♦ НМАС (HS256, HS384, HS512) — симметричная подпись. Один секретный ключ для подписания и проверки (листинг 8.3). secret := []byte("super-secret-key-min-256-bits") token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, _ := token.SignedString(secret) // Проверка token, _= jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { return secret, nil )) Плюсы: быстро (НМАС просто хеш). Минусы: все сервисы должны знать сек- рет. Если один сервис скомпрометирован или в нем обнаружена уязвимость, атакующий получает секрет и может подделывать токены для всей системы. ♦ RSA/ECDSA (RS256, ES256 и т. д.) — асимметричная подпись (листинг 8.4). // Auth-сервис зсдписывает приватным ключом token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) tokenString, := token.SignedString(privateKey)
// Любой другой сервис проверяет открытым ключом token, _= jwt.Parse(tokenScring, func(token *jwt.Token) (interface)}, error) { if _, ok := token.Method.(*jwt.SigningMethodECESA); !ok { return nil, errors.New("wrong algorithm") } return publicKey, nil }) Плюсы: приватный ключ только у auth-сервиса, остальные сервисы имеют лишь публичный ключ— не могут генерировать токены, только проверять При ком- прометации одного сервиса атакующий получает публичный ключ (который и так публичный) — бесполезно для подделки токенов. Минусы, медленнее НМАС (но для токена, который проверяется раз в несколько запросов, не критично). В микросервисной архитектуре обычно выбирают асимметричные алгоритмы — именно по причине безопасности при масштабировании. Распространение публичного ключа: JWKS Как все сервисы получают публичный ключ для проверки JWT? Можно захардкодить его в конфиг каждого сервиса, но тогда при ротации ключа (раз в год, для безопасности) придется обновлять конфиги везде. Решение: JWKS (JSON Web Key Set) — стандартный формат публикации публич- ных ключей Auth-сервис выставляет эндпоинт: GET https://duth.company.eom/.well-known/jwks, json Ответ (листинг 8.5). "keys": [ { "kty": "ЕС", "use": "sig", "kid": "key-2024-01", "erv": "P-256", "x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzG196ocCCWuis", "y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Ev5JOfSKbE", "alg" "ES256" kid (key ID) — идентификатор ключа (позволяет иметь несколько ключей одновре- менно для плавной ротации), х, у— координаты публичной точки на эллиптиче- ской кривой Р-256, закодированные в Basc64URL.
JWT header может включать kid: { "alg": "ES256", "typ": "JWT", "kid": "key-2024-01" } При проверке сервис: 1. Парсит токен, смотрит kid в header 2. Скачивает JWK.S с auth-сервера (кешируется на час-день). 3 Находит ключ с нужным kid. 4. Проверяет подпись этим ключом. При ротации ключа auth-сервис начинает подписывать новым ключом (с новым kid), но публикует оба ключа в JWKS. Старые токены проверяются старым ключом, новые — новым. Через какое-то время (время жизни токенов, обычно 24 часа) все старые токены истекают, старый ключ можно удалить из JWKS. Теперь про уязвимости Атака Algorithm substitution В 2015 году Тим Маклин обнаружил уязвимость во многих библиотеках JWT. Ата- кующий может поменять алгоритм в header с RS256 (RSA) на HS256 (НМАС) и пере- подписать токен, используя открытый ключ RSA как НМАС-секрет. Сервер хранит открытый RSA-ключ для проверки. Если библиотека берет алгоритм из header токена, она может начать проверять НМАС, используя этот же открытый ключ как секрет. Но открытый ключ известен всем, атакующий может создать ва- лидный НМАС и подделать токен. Пример атаки: 1. Сервер выдает токен с RS256, подписанный приватным RSA-ключом. 2. Владимир получает этот токен, достает открытый ключ сервера (из /jwks.json или сертификата). 3. Меняет header на {"alg":"HS256","typ":"JWT"). 4 Меняет payload: "role": "admin". 5 Подписывает HMAC-SHA256, используя открытый RSA-ключ как секрет. 6. Отправляет на сервер. Если сервер некорректно реализован algorithm = token.header["alg"] // "HS256" if algorithm. == "HS256": verify_hmac(public_key_as_secret, token) // БАЛ ЭДНА! Зашита — явно проверять алгоритм в коде, не доверять header (листинг 8.6).
- -...... . -МИД - . • Листинг 8.6 token, err := jwt. Parse (tokenString, func(token *jwt.Token) (interfaced, error) { 11 проверяем, что алгоритм именно тот, что ожидаем if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { return nil, fmt.Errcrf("unexpected method: %v", token.Header ["alg"]) ) return publicKey, nil }) Библиотека github.com/golang’jwt/jwt по умолчнию защищена и требует явного указания допустимых алгоритмов. Но многие пропускают эту проверку, чго приво- дит к уязвимостям. Уязвимость None algorithm Спецификация JWT (RFC 7519) изначально разрешала алгоритм "alg": "none" — то- кен вообще без подписи. Идея была в том, что в некоторых сценариях (например, локальное хранилище, где подпись не нужна) можно обойтись без нее Атака: 1. Берем валидный токен. 2. Меняем header: {"alg":"none", "typ":"JWT"}. 3. Убираем подпись (третья часть остается пустой или просто ставим точку). 4. Меняем payload: "role": "superadnin". 5. Отправляем: header.paylcad. Если библиотека принимает none, и разработчик не запретил это явно, то токен проходит верификацию без проверки подписи. Атакующий получает доступ с лю- быми правами. Современные библиотеки блокируют попе по умолчанию, но legacy-код может быть уязвим (листинг 8.7). Листинга.? // 3 Go НИКОГДА не делайте гак: token.Method = jwu.SigningMethodNcne II Это дыра безопасности // Правильно — запретить попе явно: if token.Method == jwt.SignirgMethodNone { return nil, errors.New("none algorithm not allowed") } Слабые секреты НМАС Для HS256 (HMAC-SHA256) безопасность зависит от длины и энтропии секрета. Ес- ли секрет короткий, его можно перебрать В 2018 году исследователи проверили топ-10000 сайтов, использующих TWT, и обнаружили, что ~5?7о используют слабые секреты вроде "secret", "password", "123456".
Атака; 1. Перехватить легитимный JWT. 2. Запустить словарный перебор; для каждого кандидата секрета вычислить НМАС и сравнить с подписью. 3. При совпадении — секрет найден, можно подделывать токены. Для 64-битного секрета (8 символов ASCII) перебор занимает минуты. Для 128- битного (16 случайных байтов) — невозможен на практике. Рекомендации. ♦ Минимум 256 битов (32 байта) случайных данных для НМАС-секрета. ♦ Генерировать через криптографический RNG: rand.Reaa(secret). ♦ Хранить в переменных окружения, не коммитить в Git. ♦ Ротировать ре1улярно (раз в квартал, год). Атаки JKUHX5U JWT header может содержать параметры jku (JSON Key URL) или x5u (X.509 URL) — указатели на удаленный сервер, где лежит открытый ключ для проверки подписи. Идея в том. что сервер может динамически подгружать ключи, не храня их локально. Атака: { "alg": "RS256", "jku": "https://attacker.com/jwks.j son" ) Если сервер наивно скачивает ключи по URL из токена (листинг 8.8), ---------------------------------— ------------------------ Лиетин! 3.I И ОПАСНО: keyURL := token.Header("jku"] keys := fetchKeys(keyURL) // Скачивает c attacker.com publicKey := keys[token.Header["kid"]] verify(publicKey, token) // Проверяет с ключом атакующего атакующий подсовывает свой ключ, подписывает токен этим ключом, и проверка проходит. Защита — whitelist доменов для j ku: allowedDomains := []string("auth.company.com", "keys.company.com") if !isAllowed(token.Header["jku"], allcwedDomains) { return errors.New("untrusted key URL") ) Или вообще игнорировать jku и хранить ключи локально — так проще и безопаснее.
Предметный указатель А ACID-гарая! ии, 94 Availability (доступность), 84 AVL-дерево, 16 В Big О (О-нотация), 9 Big Omega (li-но гания), 9 Big Tbeta (0-нотация) 9 В-деревья, 17 С САР-теорема, 83, 84, 86, 100, 101 Consistency (согласованность), 84 Е Eventual Consistency, 88 J JWT (JSON Web Token), 181 P Partition tolerance (устойчивость к разделению), 84 R Read Your Writes, 87 s Saga, 94, 95, 138
Алгоритм: О А*. 68 О Argon2, 159, 169, 174 О В1аке2, 159, 167, 174 О Brotli, 48 О ECDSA, 175 О EdDSA, 176 О LZW (Lempel-Ziv-Welch), 45 О MD5, 158, 163 О Paxos, 106 О Rijndael, 146 О RSA. 146 О SHA-1,158 О SHA-2, 159 О SHA-256,164,174 О SHA-3, 168 О Snappy, 50 О Белл мана-Форда, 65 О Бойера-Мура, 54 О Дейкстры, 60 О Диница, 76 О Диффи-Хеллмана, 154 О Кнута-Морриса-Пратта, 52 О Рабина-Карна, 57 О Фирда-Фалкерсона, 74 Асимметричное шифрование, 145, 154 Асимптотический анализ, 9 Атака: О Algorithm substitution. 186 О replay, 178 Атаки JKU и X5U. 188 Атомарность, 96 Б Бинарное дерево поиска (Binary Search Tree, BSD, 15 Блочные шифры, 149 Быстрая сортировка, 33, 34 О (Quicksort), 33 д Двоичный поиск, 28, 29, 30, 31, 33 Дерево, 15 Динамические массивы, 12 Диффузия (diffusion), 147 3 Задача поиска подстроки, 52 И Интерполяционный поиск, 30, 33 К Категории сложности алгоритмов, 10 Кодирование Хаффмана, 42, 48 Коллизии, 15, 159, 160 Консенсус, 101, 102, 111 Красно-черные деревья, 16 Криптографическая хеш-фу нкппя. 157 Криптография, 144 0 на эллиптических кривых (ЕСС), 155 0 с открытым ключом, 154 Куча, 37, 70 м Массив, И 0 фиксированной длины. 18 Механизм кворумов, 93 Мультипликативная атака, 175 О Одноразовый блокнот, 148 Односторонние функции, 153 Окно несогласованности, 94 п Пирамидальная сортировка. .33, 37 0 (Heap sort), 33 Поиск Фибоначчи, 31, 33 Поразрядная сортировка, 33, 39, 40, 42 0 (Radix sort), 33 Пороговая подпись. 179 Последовательная согласованность, 87 Потоковые шифры, 149 Принцип Керкгоффеа, 146 Причинная согласованность. 87 Протокол двухфазного коммита, 135 Путаница (confusion), 147
Р Расширение PACELC, 86 С Сбалансированное дерево, 16 Свойства ACID, 134 Связный список, 13 Симметричное шифрование. 147 Слепая подпись, 179 Сложность алгоритма, 11 Снэпшогы, 126 Соль, 167, 169, 170, 171, 172 Сортировка слиянием, 33, 36, 37 0 (Merge sort), 33 Срез, 12 Срез (slice), 18 Стандарт AES, 146 Строгая согласованность. 84. 86, 87 Структуры данных, 11 Счетчик (CTR), 150 т Теорема FLP, 104 Термы (terms), 117 У Упрощенный алгоритм Бойера-Мура Хоре пула, 55 Уязвимость None algorithm, 187 Ф Фундаментальные свойства алгоритма консенсуса, 102 X Хеширование, 57 Хеш-таблица. 14 Хеш-функции, 174 Хеш-фу нкпия, 14 ц Цепное шифрование блоков (СВС), 149 Цифровая подпись, 154, 174 ш Шифр Вернама, 148 э Эффект лавины. 160
БХВ-Электромпа bhv.ru/elements Электронные компоненты для мейкеров
Алгоритмы на языке Go Современный бэкенд — or API баз данных до вставок на Typescript, от небольших утилит до криптографических модулей и процедур для уско- рения ядра Linux — невозможно представить себе без кода на языке Go. В этой книге рассмотрены алгоритмы и их реализации на языке Go, относящиеся к следующим предметным областям. Понятие о структурах данных Поиск, сортировка и сжатие данных • Алгоритмы достижения консенсуса Алгоритмы для распределенных систем Криптографические алгоритмы Создание и защита цифровых подписей Артем Михайлов опытный программист, специалист по высоконагруженным системам, в настоящее время работает в стартапе, занятом высоконагруженны- ми приложениями ранее работал в научном кластере «Иннополис», компаниях «МТС» и «1ензор» Постоянно делится своим опытом на Хабре, пишет статьи для корпоративных блогов «Росатома», ВТБ, VK, IBS, OTUS. 191036 Санкт Петербург Гончарная ул. 20 Тел/(812) 717-10-50. 339-54-17 339-54-28 E-mail, mail@bhv.ru Internet: www.bhr.ru