Как найти дубликаты SQL-запросов: пять алгоритмов
72% запросов, которые выглядели по-разному, оказались структурно идентичны. Их разделяло только форматирование. SHA-256 от текста не нашёл бы ни одного из них.
Зачем это нужно
Архив большой ERP-системы — 30 000 SQL-запросов. Разработчики копировали процедуры, меняли отступы, переименовывали псевдонимы. Парсить все 30 000 — часы работы. Найти дубли заранее — дубли получают результат из кэша бесплатно.
Вопрос оказался сложнее, чем казалось: что считать «одинаковым»?
-- Это одно и то же? SELECT id FROM orders WHERE status = 1 SELECT id FROM orders WHERE status = 999
По тексту — разные. По структуре — одинаковые: один шаблон, разные литералы. Для дедупликации перед парсингом нужен алгоритм, который понимает структуру, а не только байты.
Мы проверили пять. Вот что получилось.
Три уровня строгости
Алгоритмы разбились на три уровня в зависимости от того, что они считают «одинаковым»:
- Уровень 1 — байтовое тождество. Лишний пробел = другой запрос.
- Уровень 2 — структурное тождество. AST совпадает, форматирование не важно.
- Уровень 3 — структурное сходство. «Похожие» запросы → один кластер.
text_hash Точные копии — SHA-256 от сырых байтов
Самый простой: SHA-256 от сырых байтов SQL-строки. Парсер не нужен. Лишний пробел — уже другой хэш.
Применение: идеален как первый фильтр. Нулевые ложные срабатывания. Но находит только буквально идентичные байты — то есть наименьшее множество дублей.
ast_hash Структурные дубли — нормализованный токен-поток
Парсер ANTLR4 строит дерево. L2HashVisitor обходит его слева направо. Узлы-правила он проходит насквозь — в хэш вносят только терминальные токены: пробелы и комментарии отброшены, ключевые слова приведены к UPPER. Накопленный поток токенов → SHA-256.
Результат: два запроса с разным форматированием, но одной структурой дают одинаковый ast_hash.
Ключевое свойство: ast_hash не зависит от форматирования — пробелы, переносы, комментарии не влияют на хэш. Именно этот алгоритм работает в продакшне в HOUND.
merkle_hash Строже: структура правил грамматики в хэше
То же дерево, но хэш идёт снизу вверх. Каждый лист хэширует свой токен. Каждый узел-правило — хэшируетcя от своего индекса rule_idx и хэшей всех своих детей. Разные правила → разные индексы → разные хэши, даже при одинаковом тексте.
Почему merkle строже ast_hash: один и тот же идентификатор в разных позициях дерева получает разный вклад в хэш, потому что rule_idx родителя разный. Например, f как псевдоним колонки (column_ref) и f как псевдоним таблицы (table_ref) дадут разные хэши. ast_hash их не различает — токен один и тот же.
rabin64 Быстрый XOR-fingerprint для файлов
Тот же нормализованный токен-поток, что у ast_hash, но CRC64 по полиному ECMA вместо SHA-256. Быстрее на CPU. Главное свойство — XOR-комбинируемость: хэш файла = XOR всех хэшей его запросов. Не нужно повторно обходить дерево.
Применение: потоковое хэширование файлов с SQL-процедурами. Можно сравнить два файла целиком без разбора содержимого.
simhash64 Похожие шаблоны — Locality-Sensitive Hashing
64-битный SimHash из семейства LSH. Каждый токен голосует за каждый из 64 битов: берём SHA-256 токена, берём i-й бит — если 1, то acc[i] += +1, если 0 — acc[i] += −1. Финальный fingerprint: бит i = 1, если acc[i] > 0.
Похожие запросы дают близкие fingerprint'ы — это не дефект, а другая способность: находить семейства шаблонов по малому расстоянию Хэмминга.
Что получилось на четырёх корпусах
30 000 SQL-запросов, два диалекта — PL/SQL и MySQL, четыре корпуса.
| Пара | Совпадение |
|---|---|
ast_hash = rabin64 | 100% |
ast_hash = merkle_hash | 100% (при единообразных кавычках) |
ast_hash ≈ simhash64 | 93–98% |
Для дедупликации перед парсингом достаточно ast_hash — самого простого из трёх.
Крупнейшая near-duplicate группа в PL/SQL-корпусе — 63 запроса CREATE TABLE с одинаковой структурой колонок, но разными именами таблиц: DWH-таблицы из одного scaffold-шаблона. ast_hash их различает (имя таблицы входит в токен-поток). SimHash объединяет — структурные токены доминируют в голосовании.
Два PL/SQL-корпуса разделяли 12 955 одинаковых ast_hash. Из них 28% — байтово идентичные. Оставшиеся 72% — одна структура, разное форматирование: один корпус прошёл через автоформат, второй нет. text_hash нашёл бы только 28%.
PL/SQL ∩ MySQL = ноль общих ast_hash. Даже SELECT id FROM t даёт разные хэши: парсеры разные, нормализация диалект-специфична. Сравнивать SQL через диалекты только хэшем нельзя.
Merkle vs ast_hash: когда что выбрать
merkle_hash включает в каждый узловой хэш индекс грамматического правила (rule_idx), поэтому один и тот же токен на разных позициях дерева даёт разные хэши. Это точнее — но именно эта точность становится проблемой в зависимости от задачи.
Когда Merkle выгоднее
- Инкрементальный парсинг. Merkle позволяет найти, какое именно поддерево изменилось между двумя версиями запроса — без повторного разбора всего AST. Достаточно сравнить хэши ветвей сверху вниз.
- Строгая синтаксическая верификация. Если требуется различать
"id"(ANSI),`id`(MySQL) иidбез кавычек как три разные сущности — Merkle делает это автоматически, без дополнительного кода. - Нормализованный вход. Когда SQL перед хэшированием проходит через единый форматтер и цитирование унифицировано, проблема кавычек исчезает — и Merkle даёт чуть более точную сигнатуру, чем ast_hash.
- Поддеревья как единицы кэширования. В некоторых query-оптимизаторах поддеревья AST кэшируются по отдельности. Merkle-хэш поддерева достаётся «бесплатно» при построении дерева снизу вверх.
Почему мы остались на ast_hash: доказательство через сложность
На нашем корпусе merkle_hash и ast_hash дали идентичный результат — расхождений не было (находка 1). Выбор определила стоимость вычисления. Докажем разницу строго.
Обозначения для одного запроса: n — число токенов (листьев AST), i — число внутренних узлов (правила грамматики).
ast_hash делает ровно 1 SHA-256-вызов на запрос.
Алгоритм: один проход по AST → токены в буфер → sha256(buffer). Одна инициализация, один финализирующий вызов. □
merkle_hash делает n + i SHA-256-вызовов на запрос.
По построению: каждый лист → SHA-256(token_bytes), каждый внутренний узел v → SHA-256(rule_idx ‖ h1 ‖ … ‖ hk). Итого ровно n + i вызовов. □
Связное дерево с n + i узлами имеет ровно n + i − 1 рёбер. Каждое ребро идёт от родителя к ребёнку, поэтому суммарное число детей равно числу рёбер:
∑ children(v) = n + i − 1
Случай 1 — нет унарных правил (каждый внутренний узел имеет ≥ 2 детей):
∑ children(v) ≥ 2i n + i − 1 ≥ 2i → i ≤ n − 1 ────────────────────────────────── n + i ≤ 2n − 1
Случай 2 — есть унарные цепочки (unit rules). Пусть H — максимальная длина цепочки (константа грамматики). В ANTLR4 MySQL для идентификатора:
tableName → fullId → uid → simpleId → ID (H = 4)
Каждый из n листьев имеет не более H унарных предков, поэтому U ≤ n·H, B ≤ n−1. Итого:
i = B + U ≤ (n − 1) + n · H = n(1 + H) − 1 ──────────────────────────────────────────── n + i ≤ n(2 + H) − 1 → O(n) при H ≤ 4. □
ast_hash : Θ(1) хэш-вызовов на запрос merkle_hash : Θ(n) хэш-вызовов на запрос, n + i ∈ [2n−1, 6n−1]
На корпусе 30 000 запросов при n ≈ 80 токенов:
ast_hash : 30 000 × 1 = 30 000 вызовов merkle_hash : 30 000 × [159, 479] ≈ 4.8 – 14.4 млн вызовов ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ соотношение : 160× – 480×
ast_hash, платите Θ(1).
Итог: какой алгоритм для чего
| Задача | Алгоритм | Условие |
|---|---|---|
| Дедупликация перед парсингом — живой legacy-код | ast_hash | вход не нормализован |
| Дедупликация — нормализованный вход | merkle_hash | единый форматтер на входе |
| Инкрементальный парсинг, diff поддеревьев | merkle_hash | нужна локализация изменений |
| Поиск шаблонных семейств (template mining) | simhash64 | допустимы ложные группировки |
| Потоковый fingerprint по файлу / пакету | rabin64 | XOR-комбинируемость |
| Точные копии, нулевая толерантность | text_hash | байтовое тождество |
Главное открытие: identity > similarity
72% пар, которые text_hash не находит — это не «похожие» запросы. Это идентичные запросы, которые текстово выглядят по-разному. Поверхностное несходство — артефакт автоматического форматирования, а не семантическое различие.
Это переворачивает привычную интуицию: когда задача — дедупликация перед парсингом, точное структурное совпадение надёжнее семантического сходства. SimHash находит «похожие», но пропускает часть «идентичных» (Hamming-порог не равен нулю). ast_hash находит именно идентичные — и только их.
Источники методов
| Алгоритм | Авторы / организация | Год | Публикация |
|---|---|---|---|
text_hash |
NIST | 2015 | FIPS PUB 180-4 — Secure Hash Standard (SHS) |
ast_hash |
HOUND · внутренняя | 2024 | Реализация L2HashVisitor. Концепция AST-дедупликации: Jiang et al., DECKARD: Scalable and Accurate Tree-Based Detection of Code Clones, ICSE 2007 |
merkle_hash |
Ralph C. Merkle | 1987 | A Digital Signature Based on a Conventional Encryption Function — CRYPTO 1987, LNCS 293, pp. 369–378 |
rabin64 — концепция |
Michael O. Rabin | 1981 | Fingerprinting by Random Polynomials — Harvard University TR-15-81 |
rabin64 — полином |
ECMA | 1992 | ECMA-182. Полином CRC64: 0xC96C5795D7870F42 |
simhash64 — метод |
Moses S. Charikar | 2002 | Similarity Estimation Techniques from Rounding Algorithms — STOC 2002, pp. 380–388 |
simhash64 — масштаб |
Manku, Jain, Das Sarma | 2007 | Detecting Near-Duplicates for Web Crawling — WWW 2007, pp. 141–150. 8 млрд страниц, Hamming ≤ 3 |