MEASUREMENT
bench/isolation/cost.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры: что уровень изоляции пропускает и во что обходится
Три скрипта, все против живого PostgreSQL. Первый отвечает на вопрос «что проходит», второй — «сколько это стоит», третий — «можно ли обойтись без верхнего уровня».
| скрипт | что показывает |
|---|---|
anomalies.py |
четыре аномалии на трёх уровнях, воспроизведением: неповторяющееся чтение, фантом, потерянное обновление, write skew — и где именно приходит отказ |
cost.py |
пропускная способность, разброс между кругами и доли отказов 40001 и 40P01 порознь |
explicit_locking.py |
держит ли инвариант SELECT ... FOR UPDATE на слабом уровне и где этот приём молча не срабатывает |
Запуск (нужен живой сервер; адрес — в DE_BENCH_DSN):
DE_BENCH_DSN="postgres://postgres@localhost:5433/postgres" \
python3 bench/isolation/anomalies.py
DE_BENCH_DSN="postgres://postgres@localhost:5433/postgres" \
python3 bench/isolation/cost.py
DE_BENCH_DSN="postgres://postgres@localhost:5433/postgres" \
python3 bench/isolation/explicit_locking.py
Почему аномалии воспроизводятся, а не описываются
Про уровни изоляции почти всегда рассказывают таблицей из стандарта SQL. Таблица описывает, что уровню разрешено пропускать, а не что он пропускает у вас, — и расходится с действительностью в обе стороны сразу. Проверить это можно только запуском, поэтому здесь запуск.
Порядок шагов задан явно, без пауз-угадаек. Каждая аномалия — это две транзакции с предписанным чередованием, а не два потока в надежде, что они столкнутся. Результат поэтому не зависит от скорости машины и повторяется.
Ошибки этих замеров, оставленные в истории
Первая редакция anomalies.py утверждала в заголовке, что PostgreSQL молча
подменяет запрошенный READ UNCOMMITTED на READ COMMITTED. Запуск это
опроверг: transaction_isolation возвращает ровно read uncommitted — имя
уровня сохраняется. Подменяется поведение, и проверять надо его: соседняя
транзакция меняет строку и не фиксирует, а мы пробуем это увидеть.
Замечание не о PostgreSQL, а о методе: утверждение про имя настройки и утверждение про поведение — разные утверждения, и первое ничего не говорит о втором. Скрипт теперь печатает оба.
Вторая: cost.py складывал 40001 и 40P01 в один счётчик, а колонку
подписывал «отказов 40001». Это разные SQLSTATE — serialization_failure и
deadlock_detected, — и подпись величины обязана соответствовать тому, что
посчитано, независимо от того, повезло ли в конкретном прогоне. Счётчики
разделены; дедлоков за все прогоны не случилось ни одного, и теперь это видно
в отчёте явной строкой, а не предполагается.
Третья: пять кругов оказалось мало для того вывода, который на них строился. По лучшему из пяти кругов выходило, что на редких столкновениях SERIALIZABLE опережает REPEATABLE READ. Девять кругов показали, что порядок между этими двумя от круга к кругу переворачивается, а разница между ними меньше собственного разброса каждого. Вывод «SERIALIZABLE впереди» снят как неподтверждённый, а в отчёт добавлены худший круг и строка «впереди в N из 9»: без них число выглядит точнее, чем оно есть.
Протокол замера цены
Тот же, что в питоновских замерах корпуса. Уровни меряются не подряд, а кругами: в каждом круге проходят все три, для каждого берётся лучший круг. Пока замеры шли подряд, просадка машины в окне одного уровня целиком доставалась ему и портила отношение между уровнями.
Кругов девять, и разброс печатается рядом с результатом. Лучший круг — это оценка сверху, и по одному такому числу нельзя отличить настоящую разницу от шума. Поэтому в таблице стоит и худший круг, и доля разброса, и то, сколько раз из девяти один уровень обогнал другой. Правило чтения простое: если разница между двумя уровнями меньше разброса каждого из них, замер их не различает — и писать «этот быстрее» нельзя.
Наборы строк сравнивать между собой нельзя. У набора из 4 строк и набора из 64 разная вероятность столкнуться, и числа между ними несопоставимы — сравнивать надо уровни внутри одного набора. Ради этого набора и два: в этой нагрузке плотность соперничества двигает цену сильнее, чем переход между двумя верхними уровнями. Это не значит, что уровень ни при чём: у READ COMMITTED доля отказов нулевая на обоих наборах, у верхних — нет.
Script
252 lines"""Во что обходится SERIALIZABLE — в отказах, а не в скорости запроса.
ЗАЧЕМ ЭТОТ СКРИПТ. Про SERIALIZABLE говорят «он медленный», и на этом
разговор обычно кончается. Замер показывает, что в этой нагрузке дорожает не
сам запрос: пропускная способность SERIALIZABLE держится рядом с REPEATABLE
READ. Платится другим — отказами: часть транзакций завершается с 40001, и
работу приходится делать заново. Насколько велика эта доля, в замере сильнее
зависит от плотности соперничества, чем от выбора между двумя верхними
уровнями.
ЧТО ИМЕННО МЕРЯЕТСЯ. N рабочих в цикле делают «прочитать — изменить —
записать» по случайной строке из небольшого набора. Чем меньше набор, тем
чаще они сталкиваются. Считается: сколько транзакций дошло до конца за
отведённое время, сколько попыток на это ушло и какая доля попыток
завершилась отказом.
ПОЧЕМУ ОТКАЗЫ СЧИТАЮТСЯ ПОРОЗНЬ. 40001 (serialization_failure) и 40P01
(deadlock_detected) — разные SQLSTATE, и складывать их в один счётчик,
подписанный «40001», нельзя: подпись перестанет соответствовать тому, что
посчитано. Первая редакция этого скрипта делала ровно так; колонки разделены
после аудита. Отдельно считается и МЕСТО отказа — на выполнении команды или
на фиксации, — потому что расхожее «SERIALIZABLE отказывает на COMMIT» верно
не всегда: смотрите колонку «на команде».
ПРОТОКОЛ ЗАМЕРА — тот же, что в питоновских замерах корпуса: уровни
меряются НЕ подряд, а кругами (в каждом круге все три), и берётся лучший
круг для каждого уровня. Причина не теоретическая: пока замеры шли подряд,
просадка машины в окне одного уровня целиком доставалась ему и портила
отношение.
ЗАПУСК:
DE_BENCH_DSN="postgres://postgres@localhost:5433/postgres" \\
python3 bench/isolation/cost.py
"""
import os
import random
import statistics
import threading
import time
import psycopg
DSN = os.environ.get("DE_BENCH_DSN", "postgres://postgres@localhost:5433/postgres")
LEVELS = ["READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"]
WORKERS = 8
SECONDS = 1.5
# Кругов девять, а не пять: разница между REPEATABLE READ и SERIALIZABLE
# оказалась одного порядка с разбросом между кругами, и чтобы сказать об этом
# честно, разброс надо измерить, а не предположить.
ROUNDS = 9
# Чем меньше строк, тем выше соперничество. Две колонки набора — чтобы
# показать, что цена уровня определяется пересечением, а не самим уровнем.
ROW_SETS = [4, 64]
def prepare(rows: int) -> None:
with psycopg.connect(DSN, autocommit=True) as conn, conn.cursor() as cur:
cur.execute("DROP TABLE IF EXISTS counters")
cur.execute("CREATE TABLE counters (id int primary key, n int not null)")
cur.executemany("INSERT INTO counters VALUES (%s, 0)", [(i,) for i in range(rows)])
class Tally:
"""Счётчики одного рабочего.
Отказы разложены по двум осям сразу: КАКОЙ SQLSTATE и ГДЕ он случился.
Обе оси нужны для утверждений, которые статья делает вслух.
"""
__slots__ = ("done", "attempts", "ser_stmt", "ser_commit", "dl_stmt", "dl_commit")
def __init__(self) -> None:
self.done = self.attempts = 0
self.ser_stmt = self.ser_commit = 0
self.dl_stmt = self.dl_commit = 0
def worker(level: str, rows: int, stop: float, out: list) -> None:
"""Пока не вышло время: прочитать, прибавить, записать. Отказ — повтор."""
t = Tally()
rnd = random.Random(threading.get_ident())
with psycopg.connect(DSN) as conn:
conn.autocommit = False
with conn.cursor() as cur:
while time.perf_counter() < stop:
key = rnd.randrange(rows)
t.attempts += 1
# Тело транзакции и фиксация ловятся ПОРОЗНЬ: иначе нельзя
# сказать, где именно уровень остановил работу.
try:
cur.execute(f"BEGIN ISOLATION LEVEL {level}")
cur.execute("SELECT n FROM counters WHERE id = %s", (key,))
n = cur.fetchone()[0]
cur.execute("UPDATE counters SET n = %s WHERE id = %s", (n + 1, key))
except psycopg.errors.SerializationFailure:
conn.rollback()
t.ser_stmt += 1
continue
except psycopg.errors.DeadlockDetected:
conn.rollback()
t.dl_stmt += 1
continue
try:
conn.commit()
t.done += 1
except psycopg.errors.SerializationFailure:
conn.rollback()
t.ser_commit += 1
except psycopg.errors.DeadlockDetected:
conn.rollback()
t.dl_commit += 1
out.append(t)
class Round:
"""Итог одного круга по одному уровню."""
__slots__ = ("tps", "ser_pct", "dl_pct", "stmt_share")
def __init__(self, tps: float, ser_pct: float, dl_pct: float, stmt_share: float) -> None:
self.tps = tps
self.ser_pct = ser_pct
self.dl_pct = dl_pct
self.stmt_share = stmt_share
def measure(level: str, rows: int) -> Round:
"""Один круг: транзакций в секунду и доли отказов среди попыток."""
prepare(rows)
results: list = []
stop = time.perf_counter() + SECONDS
threads = [
threading.Thread(target=worker, args=(level, rows, stop, results))
for _ in range(WORKERS)
]
started = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.perf_counter() - started
done = sum(r.done for r in results)
attempts = sum(r.attempts for r in results)
ser = sum(r.ser_stmt + r.ser_commit for r in results)
dl = sum(r.dl_stmt + r.dl_commit for r in results)
ser_stmt = sum(r.ser_stmt for r in results)
pct = lambda k: (k / attempts * 100 if attempts else 0.0) # noqa: E731
return Round(
done / elapsed,
pct(ser),
pct(dl),
(ser_stmt / ser * 100 if ser else 0.0),
)
def main() -> None:
with psycopg.connect(DSN, autocommit=True) as conn, conn.cursor() as cur:
cur.execute("SHOW server_version")
version = cur.fetchone()[0]
print(f"PostgreSQL {version} | цена уровня изоляции при соперничестве")
print(f" {WORKERS} рабочих, круги по {SECONDS} с, лучший из {ROUNDS} кругов")
print()
deadlocks_seen = False
for rows in ROW_SETS:
# Круги чередуются по уровням: см. протокол в заголовке файла.
rounds: dict[str, list[Round]] = {lvl: [] for lvl in LEVELS}
for _ in range(ROUNDS):
for level in LEVELS:
rounds[level].append(measure(level, rows))
best = {lvl: max(rs, key=lambda r: r.tps) for lvl, rs in rounds.items()}
if any(r.dl_pct for rs in rounds.values() for r in rs):
deadlocks_seen = True
print(f" НАБОР ИЗ {rows} СТРОК" + (" — плотное соперничество" if rows == 4 else " — редкие столкновения"))
print(
f" {'уровень':<18} {'лучший круг':>12} {'худший круг':>12} "
f"{'разброс':>8} {'40001':>8} {'40P01':>8} {'из них на команде':>18}"
)
for level in LEVELS:
r = best[level]
tps_all = [x.tps for x in rounds[level]]
lo, hi = min(tps_all), max(tps_all)
place = f"{r.stmt_share:.0f}%" if r.ser_pct else "—"
print(
f" {level:<18} {hi:>12.0f} {lo:>12.0f} "
f"{(hi - lo) / hi * 100:>7.0f}% "
f"{r.ser_pct:>7.1f}% {r.dl_pct:>7.1f}% {place:>18}"
)
# Главный вопрос набора: устойчив ли порядок двух верхних уровней.
rr, ser = rounds["REPEATABLE READ"], rounds["SERIALIZABLE"]
ahead = sum(1 for a, b in zip(rr, ser) if b.tps > a.tps)
print(
f" по кругам SERIALIZABLE впереди REPEATABLE READ "
f"в {ahead} из {ROUNDS}"
)
print()
print("ЧТО ИЗ ЭТОГО СЛЕДУЕТ")
print(" Сравнивать надо строки внутри одного набора, а не наборы между")
print(" собой: у них разное число строк и разная вероятность столкнуться.")
print()
print(" 1. РАЗРЫВ МЕЖДУ REPEATABLE READ И SERIALIZABLE МЕНЬШЕ ШУМА. Он")
print(" единицы процентов при разбросе кругов в два-три десятка, и")
print(" порядок между этими двумя от круга к кругу переворачивается —")
print(" см. строку «впереди в N из 9». Сказать по этому замеру, что")
print(" один из них быстрее другого, нельзя.")
print()
print(" 2. РАЗРЫВ С READ COMMITTED ЗАВИСИТ ОТ ПЛОТНОСТИ. На четырёх")
print(" строках он больше чем вдвое и разбросом не объясняется. На")
print(" шестидесяти четырёх он сжимается до четверти и становится")
print(" сравним с разбросом. То есть разрыв создаёт не сам выбор")
print(" уровня, а то, как часто транзакции встречаются на одной строке.")
print()
print(" 3. НО «УРОВЕНЬ НИ ПРИ ЧЁМ» — ТОЖЕ НЕВЕРНО. У READ COMMITTED доля")
print(" отказов нулевая на обоих наборах, у двух верхних — нет. Уровень")
print(" задаёт, КАКИЕ истории база отвергнет; плотность задаёт, КАК")
print(" ЧАСТО это случится. В этом замере сильнее двигает второе.")
print()
print(" 4. ЭТО ВЫВОД ПРО ЭТУ НАГРУЗКУ, А НЕ ПРО УРОВНИ ВООБЩЕ. У SSI есть")
print(" собственная стоимость — отслеживание зависимостей и предикатные")
print(" блокировки, — и на другой форме транзакций она может выйти")
print(" из-под разброса. Здесь не вышла.")
print()
print(" 5. МЕСТО ОТКАЗА НЕ ОДНО. Почти все 40001 здесь приходят на команде:")
print(" UPDATE натыкается на строку, изменённую после снимка. На")
print(" фиксации отказ приходит только у SERIALIZABLE и только там, где")
print(" столкновения редки, — обе транзакции успевают дойти до конца, и")
print(" цикл зависимостей виден лишь на COMMIT. Поэтому повторять надо")
print(" транзакцию целиком, а не оборачивать одну фиксацию.")
print()
print(
" Дедлоков (40P01) за прогон: "
+ ("были, см. колонку" if deadlocks_seen else "ни одного")
+ ". Колонки разделены, чтобы это было видно, а не предполагалось."
)
if __name__ == "__main__":
main()