2025, Oct 30 22:16

Совместимость HASHBYTES SHA‑256 и BIGINT между SQL Server и PySpark

Разбираем, почему CAST(HASHBYTES('SHA2_256') AS BIGINT) в SQL Server даёт другое число, чем в PySpark, и показываем, как получить идентичный 64‑битный ключ.

При переносе ETL-логики из Synapse SQL в PySpark часто нужно воспроизвести существующий идентификатор строки, построенный с помощью HASHBYTES. Само значение SHA‑256 может совпадать побайтно между SQL и Python, но производный BIGINT расходится. В этом материале подробно показано, почему так происходит и как получить совместимое до бита 64‑битное целое.

Подготовка: как ключ формируется в SQL

В SQL идентификатор создаётся так: хешируется конкатенация значений столбцов с разделителем «|», после чего хеш приводится к BIGINT:

SELECT CAST(HASHBYTES('SHA2_256', CONCAT_WS('|', [col1], [col2], ...)) AS BIGINT) AS EtlHashKey
FROM sample_table;

Для простого примера, col1 = abc и col2 = 123, ожидается конкретное отрицательное значение BIGINT в SQL. При тех же входных данных в Python может получиться другой 64‑битный результат, даже если шестнадцатеричные дайджесты SHA‑256 совпадают. Именно это расхождение и является проблемой.

Воспроизведение проблемы в Python

Пример ниже показывает, что даже при, казалось бы, корректном подходе получается другой BIGINT по сравнению с SQL. Используются те же кодировка utf‑8 и обработка null, что и у CONCAT_WS, а шестнадцатеричный SHA‑256 совпадает с результатом HASHBYTES в SQL.

import hashlib
import struct
def calc_row_key(*parts):
    merged = '|'.join(['' if p is None else str(p) for p in parts])
    digest = hashlib.sha256(merged.encode('utf-8')).digest()
    key64 = struct.unpack('<q', digest[:8])[0]
    return key64

Код выдаёт корректное знаковое 64‑битное число, но оно не совпадает с результатом SQL CAST(HASHBYTES(...) AS BIGINT).

Почему числа расходятся

SHA‑256 возвращает 32 байта. Несоответствие возникает из‑за того, как эти 32 байта сводятся к 8 и как затем эти 8 байт интерпретируются. SQL Server берёт правые 8 байт 32‑байтового хеша и трактует их как BIGINT. А приведённый выше Python‑код берёт первые 8 байт и распаковывает их в порядке little‑endian.

Если выбрать правильные 8 байт и порядок байтов, итоговое 64‑битное число совпадёт с поведением CAST в SQL Server. Сырые значения хеша и так совпадали; отличались лишь выбранные 8 байт и способ их чтения.

Решение: повторить семантику CAST в SQL

Чтобы воспроизвести CAST(HASHBYTES('SHA2_256', ...) AS BIGINT), возьмите последние 8 байт дайджеста SHA‑256 и распакуйте их как знаковое 64‑битное целое в порядке big‑endian.

import hashlib
import struct
def build_etl_key(*fields):
    joined = '|'.join(['' if f is None else str(f) for f in fields])
    hbytes = hashlib.sha256(joined.encode('utf-8')).digest()
    etl_key = struct.unpack('>q', hbytes[-8:])[0]
    return etl_key

Эта правка выравнивает результат Python с BIGINT, полученным в SQL Server из HASHBYTES. Ключевые детали — правые 8 байт и интерпретация в формате big‑endian.

Почему это важно понимать

Сведение 32‑байтового дайджеста SHA‑256 к 8‑байтовому целому отбрасывает три четверти информации. Из‑за этого вероятность коллизий выше, чем при использовании полного бинарного значения. Такая практика сохраняется в некоторых конвейерах по историческим причинам или из‑за ограничений схемы, но важно осознавать компромисс. Если есть возможность хранить полный хеш как бинарные данные, шаг преобразования отпадает, а вероятность коллизий минимизируется относительно исходного алгоритма.

Итоги на практике

Если вам нужно получить тот же BIGINT, что и в SQL Server при CAST(HASHBYTES('SHA2_256', ...) AS BIGINT), вычислите SHA‑256, возьмите правые 8 байт и распакуйте их как знаковое 64‑битное значение в порядке big‑endian. Помните, что BIGINT хранит лишь часть сведений из хеша, поэтому, когда это возможно, предпочтительнее сохранять полный 32‑байтовый дайджест, чтобы лучше сохранять свойства уникальности.

Статья основана на вопросе на StackOverflow от Santosh Reddy Kommidi и ответе Charlieface.