Алгоритмы поиска c: 4 ключевых метода индексации данных
Индекс помогает найти нужные строки без полного просмотра таблицы, но не каждый запрос и не каждая база обязаны им пользоваться.

Как выбор структуры определяет скорость ответа
Планировщик может выбрать последовательное сканирование, если таблица небольшая, условие затрагивает большую часть строк или индексный доступ обойдётся дороже. Поэтому фраза «запрос идёт по индексу» описывает возможный план выполнения, а не универсальное правило.
Когда индекс подходит, его устройство определяет, какие операции будут быстрыми и сколько ресурсов потребуется на запись. Упорядоченное дерево удобно для диапазонов и сортировки, хэш-таблица — для точного равенства, инвертированный индекс — для поиска по словам и элементам составных значений. Блочные структуры сокращают область чтения в больших таблицах, если физическое расположение данных этому помогает.
У этих методов разные задачи, и сравнивать их только по одной оценке сложности мало полезно. На практике важны формат данных, доля совпадающих строк, распределение значений и стоимость обновления индекса. Разработчику на C приходится учитывать то же самое: структура данных ускоряет одни операции, но добавляет расходы на хранение и поддержку.
B-деревья: логарифмическая сложность диапазонного поиска
B-дерево хранит ключи в упорядоченном виде. Каждый узел содержит несколько ключей и ссылки на дочерние узлы, а сбалансированная структура удерживает листья на одинаковой глубине. Поиск значения проходит от корня к листу; для дерева с n ключами его типичная оценка составляет O(log n).
В отличие от хэш-индекса, B-дерево сохраняет порядок ключей. Это позволяет использовать его для точного равенства, а также для запросов с диапазоном: например, найти записи между двумя датами или все значения больше заданного порога. После поиска первой подходящей записи можно читать соседние ключи в порядке индекса. Такой проход часто выгоднее, чем проверять строки по одной, но фактическая стоимость зависит от объёма совпадений и плана запроса.
Упорядоченность полезна и для ORDER BY: если индекс и условия запроса подходят друг другу, базе может не понадобиться отдельная сортировка. Но само наличие индекса не гарантирует, что сортировка исчезнет. Например, планировщик может предпочесть другой путь, если он считает его дешевле. С OFFSET тоже нет волшебства: индекс способен дать нужный порядок, однако пропуск большого числа строк всё равно требует работы. Для глубокой пагинации часто ищут следующий фрагмент относительно последнего увиденного ключа, а не просят базу снова пройти длинный список смещённых строк.
В реляционных СУБД B-дерево обычно служит универсальным индексом для упорядочиваемых значений. В PostgreSQL тип jsonb также поддерживает B-tree-индекс для сравнения целых JSONB-значений. Это не заменяет GIN во всех сценариях: поиск по наличию ключей или включению фрагмента JSONB обычно требует другого набора операторов и подходящего класса индекса.
B-дерево особенно полезно там, где важен порядок: для диапазонов, сортировки и последовательного чтения соседних значений. Но окончательный выбор всё равно делает планировщик, исходя из конкретного запроса.
Формулировка «B+-дерево» подчёркивает вариант, где внутренние узлы служат для навигации, а записи или ссылки на них размещаются в листьях. Детали реализации различаются между СУБД, поэтому не стоит переносить описание одной конкретной реализации на все базы. Общий принцип остаётся практичным: высокий ветвящийся узел дерева позволяет искать нужный ключ, не просматривая каждую строку таблицы.
Для разработчика на C здесь важны не только асимптотическая оценка, но и устройство страниц, размер узла, локальность чтения и поддержка дерева при вставках. В памяти маленькая структура может вести себя иначе, чем индекс, который постоянно читается с диска. Алгоритм задаёт пределы, но производительность проявляется в конкретной реализации и нагрузке.
Хэш-индексы: точное совпадение без порядка
Хэш-индекс применяет хэш-функцию к ключу и использует результат, чтобы найти соответствующую область таблицы. Его естественный сценарий — проверка точного равенства: например, поиск записи по токену или идентификатору. При удачном распределении ключей ожидаемое число операций может быть близким к константе, но это не гарантия O(1) для любого запроса и любого состояния индекса. Коллизии и рост структуры влияют на работу.
Хэширование не сохраняет порядок исходных значений. Поэтому хэш-индекс не подходит для диапазонных условий вроде > и BETWEEN, а также не помогает выдать ключи в сортированном порядке. Если запросу нужны разные типы операций, B-дерево часто оказывается более гибким выбором, даже если для равенства хэш-индекс тоже применим.
У хэш-индексов нет общего требования помещаться в оперативную память. Индексы СУБД могут храниться на диске; доступ к страницам кэшируется, а стоимость зависит от реализации, размера данных и характера нагрузки. Случайное чтение действительно может быть дорогим, но оно не превращает саму структуру в исключительно оперативную.
В PostgreSQL хэш-индексы стали журналируемыми через WAL и устойчивыми к восстановлению после сбоя начиная с версии 10. Это сняло важное ограничение прежних версий. Однако наличие поддержки не делает хэш-индекс лучшим вариантом по умолчанию: нужно сопоставить его возможности с операторами запросов и проверить план выполнения.
Для JSONB выбор зависит от задачи. Сравнение целых значений такого типа может поддерживаться B-tree, а поиск по содержимому и наличию элементов обычно связан с GIN. Нельзя заключать, что ключ JSONB автоматически требует хэш-индекса: тип и оператор должны соответствовать классу индекса.
Инвертированные индексы и GIN: поиск по содержимому
Инвертированный индекс связывает отдельный элемент с перечнем документов или строк, где этот элемент встречается. Для текста элементом становится токен, для массива — его значение, для JSONB — ключ или другой индексируемый компонент. Вместо повторного просмотра каждого документа поисковая система обращается к спискам соответствий.
При построении полнотекстового индекса текст сначала проходит обработку: токенизатор выделяет слова, а анализатор может нормализовать формы и отбросить элементы, которые не участвуют в поиске. Затем для токена хранится список записей, часто называемый posting list. В зависимости от системы в нём могут учитываться идентификаторы документов, позиции слов или дополнительная информация для вычисления релевантности.
Для запроса из нескольких слов нужно получить соответствующие списки и объединить или пересечь их согласно логике поиска. Редкие слова обычно дают короткие списки, частые — длинные. Поэтому цена запроса зависит не только от количества слов, но и от частоты совпадений, способов сжатия списков и алгоритма их обхода. В поисковых движках применяют разные методы ускорения, в том числе пропуски через часть списка, чтобы не сравнивать каждую запись по очереди.
В PostgreSQL обобщённый инвертированный индекс называется GIN. Он применяется, в частности, для полнотекстового поиска, массивов и JSONB. Поддерживаемые операции зависят от типа столбца и класса операторов; среди распространённых сценариев есть проверка включения, поиск ключей и сопоставление полнотекстового вектора с запросом.
Называть стоимость поиска по GIN просто O(1) нельзя. Индекс использует упорядоченную структуру для поиска ключа, а затем работает со списком соответствующих значений. Если запрос объединяет несколько условий, стоимость зависит и от длины posting lists, и от способа их пересечения. GIN может быть очень эффективен для подходящего поиска по содержимому, но его скорость не сводится к константному обращению.
В PostgreSQL есть режим fastupdate, который буферизует новые элементы в pending list, чтобы уменьшить стоимость отдельных вставок в основную структуру. Запросы при этом проверяют и основную часть GIN, и pending list: свежие записи не становятся невидимыми для поиска. Когда буфер достигает порога, данные переносятся в основную структуру. Такой режим меняет характер и распределение затрат, но не создаёт паузу, в течение которой новые данные нельзя найти. При выборе режима учитывают баланс между скоростью записи и последующей работой с pending list.
Представим архив документов, где нужный материал известен по нескольким словам, но не по точному заголовку. Инвертированный индекс позволяет найти записи, связанные с каждым словом запроса, и сформировать пересечение кандидатов. Полное сканирование корпуса остаётся возможным для иных планов и небольших наборов данных, но для большого текстового массива индекс даёт специализированный путь поиска.
Bitmap и BRIN: фильтрация блоков и наборов строк
Bitmap-индексы и BRIN решают задачи, отличные от поиска по отдельному текстовому слову. Их объединяет то, что они могут помочь сузить область чтения, но механика и применимость у них разные.
В некоторых СУБД bitmap-индекс хранится как постоянная структура: для значений формируются битовые карты строк. Несколько карт можно комбинировать побитовыми операциями, например при условиях по нескольким столбцам. Такой подход бывает полезен для столбцов с небольшим числом возможных значений и запросов, которые выбирают сочетания условий. При частых изменениях данных поддерживать карты может быть затратно, поэтому пригодность зависит от профиля записи.
В PostgreSQL bitmap scan устроен иначе: планировщик может получить битовые карты на лету из одного или нескольких индексов, а затем читать подходящие страницы таблицы. Это не постоянный bitmap-индекс на диске. Карты помогают совместить результаты нескольких условий и сгруппировать чтение строк по страницам, но не отменяют работу с самой таблицей.
BRIN в PostgreSQL хранит сводные сведения о диапазонах физических блоков, а не отдельную запись для каждой строки. Для столбца с временными метками, например, индекс может фиксировать минимальное и максимальное значение в каждом диапазоне блоков. Если запрос ищет записи после заданной даты, диапазоны, которые заведомо не подходят, можно пропустить.
BRIN компактен и особенно уместен, когда значения столбца связаны с физическим порядком строк. Это часто встречается в журналах, куда данные поступают примерно в хронологическом порядке. Если строки перемешаны, диапазоны значений перекрываются, и индекс отбрасывает меньше блоков. Тогда его преимущество снижается. BRIN не упорядочивает таблицу сам по себе и не заменяет B-дерево для любого диапазонного поиска.
BRIN экономит место, когда физический порядок таблицы несёт полезную информацию. Если значения разбросаны по блокам случайно, компактный индекс может почти не сузить чтение.
Сравнение структур: что именно ускоряется
| Структура | Сильная сторона | Ограничение | Типичный сценарий |
|---|---|---|---|
| B-дерево | Точное равенство, диапазоны, упорядоченное чтение | Размер и обслуживание растут вместе с индексируемыми данными | Даты, числа, сортировка |
| Хэш-индекс | Поиск по точному равенству | Не поддерживает порядок и диапазоны | Точечный поиск по ключу |
| GIN | Поиск по словам и компонентам составных значений | Стоимость зависит от posting lists; вставки могут быть затратными | Полнотекстовый поиск, массивы, JSONB |
| Bitmap scan | Сочетание условий и чтение подходящих страниц | Карта в PostgreSQL создаётся для плана запроса, а не хранится как отдельный индекс | Запросы с несколькими фильтрами |
| BRIN | Компактное исключение неподходящих диапазонов блоков | Эффективность зависит от связи значения с физическим порядком | Крупные таблицы журналов |
У таблицы есть важное ограничение: она сравнивает семейства подходов, а не обещает одинаковое поведение во всех СУБД. Даже внутри одной базы итог зависит от операторов, классов индекса, настроек и распределения данных. Оценка сложности полезна как ориентир, но не заменяет анализ фактического плана.
Балансировка нагрузки: почему индексы замедляют запись
Индекс ускоряет некоторые чтения за счёт дополнительной структуры, которую нужно хранить и обновлять. При вставке строки база добавляет запись в таблицу и поддерживает каждый затронутый индекс. Для B-дерева это означает поиск позиции и изменение узлов; для GIN операция может затронуть буферизованный список, а затем потребовать переноса данных; для BRIN обновляются сводные сведения о диапазоне блоков. Конкретные расходы зависят от структуры и реализации.
При UPDATE важно, какие именно столбцы меняются. Если обновлён индексируемый столбец, соответствующую структуру, как правило, нужно изменить. Изменение неиндексируемого поля может иметь другую стоимость. В PostgreSQL также имеют значение особенности хранения версий строк и возможность выполнить обновление без изменения индексов. Поэтому формула «каждый индекс замедляет каждую запись одинаково» неверна, хотя большое число индексов обычно усложняет запись и занимает место.
Нельзя задать универсальное оптимальное число индексов на таблицу. Для одной нагрузки несколько индексов могут обслуживать важные запросы, а для другой даже один редко используемый индекс окажется лишним расходом. Проверять стоит не количество само по себе, а то, какие запросы индекс поддерживает, как часто его используют и какой ценой он обновляется.
Есть и обратная сторона: индекс не обязательно ускоряет чтение. Если таблица мала, последовательное сканирование может оказаться дешевле. Если условие выбирает большую долю строк, базе иногда выгоднее прочитать таблицу целиком, чем идти по индексу и затем обращаться к множеству строк в разных страницах. Индекс по столбцу с низкой селективностью не бесполезен автоматически, но его полезность нельзя определить только по названию столбца или проценту совпадений.
Перед добавлением структуры стоит посмотреть план запроса, например через EXPLAIN ANALYZE в PostgreSQL, и проверить его на характерных данных. Полезно сравнить оценку планировщика с фактическим числом строк, временем и тем, как запрос ведёт себя при росте таблицы. После изменений нужно учитывать и цену записи, а не только ускорение одного удачного чтения.
Поисковые системы и реализация на C
Реляционная СУБД и поисковый движок могут использовать похожие идеи, но это не означает, что они построены из одного и того же набора индексов. Elasticsearch использует Lucene: инвертированный индекс важен для текстового поиска, а для других задач применяются отдельные структуры и способы хранения полей. B-дерево, хэш-индекс, GIN, BRIN и bitmap нельзя без оговорок представить как обязательную комбинацию внутри каждого поискового движка.
Ранжирование тоже следует отделять от поиска кандидатов. Инвертированный индекс помогает найти документы, содержащие термины запроса; затем система может оценить их релевантность, учитывая частоту слов, длину документа и другие признаки. Модели вроде BM25 относятся к этапу оценки совпадений, а не к механике B-дерева или хэш-таблицы. В более сложных системах к этому добавляют переоценку кандидатов другими методами.
Поисковые алгоритмы на языке Си опираются на те же базовые компромиссы. Для словаря можно выбрать дерево или компактное представление префиксов, для найденных документов хранить списки идентификаторов, а для объединения результатов подобрать алгоритм с учётом длины списков. Если данные помещаются в память, важны расположение и размер структур; если поиск идёт по диску, на первый план выходят объём чтения и локальность доступа.
Разработка поискового движка поэтому начинается с характера запросов и данных, а не с выбора модной структуры. Нужны ли диапазоны, точные совпадения, полнотекстовый матчинг или фильтры по временным интервалам? Как часто индекс обновляется? Как распределены значения? Ответы подскажут, где оправдано дерево, где нужен инвертированный список, а где достаточно последовательного чтения.
Индексирование не превращает любой запрос в мгновенный. Оно создаёт дополнительные пути доступа и переносит часть работы на этап записи и обслуживания. B-дерево остаётся универсальным выбором для упорядоченных значений, хэширование подходит для точного равенства, GIN помогает искать по компонентам документов, а BRIN использует физический порядок больших таблиц. Хороший план появляется тогда, когда структура соответствует запросу, а не просто присутствует в схеме.