Начало работы

Базы данных из CLI

Пишем и запускаем нагрузочный тест базы данных через perfscale CLI — шаги для PostgreSQL, MySQL/MariaDB и SQLite, YAML-конфиг, perfscale run и сводка метрик db_*

Обзор

Open-source-движок perfscale нагружает PostgreSQL, MySQL/MariaDB и SQLite прямо из терминала — без аккаунта на платформе, бесплатно в std-тире, как и шаги WebSocket и gRPC. Семейство std/db-*@v1 покрывает подключения, параметризованные запросы и многостейтментные транзакции, измеряемые как одно целое. Эта страница проведёт тест от YAML до сводки метрик.

1. Установите CLI

npm install -g @perfscale/exe

Или скачайте бинарник под свою платформу со страницы GitHub Releases. У бинарника нет runtime-зависимостей — нативный движок встроен.

2. Уберите DSN из файла теста

Никогда не вписывайте пароль в YAML. Положите строку подключения в блок variables: конфиг-файла — файла, который не попадает в git или генерируется в CI, — и ссылайтесь на него интерполяцией:

# db.config.yaml
vus: 5
duration: 30s
variables:
  db_dsn: postgres://bench:secret@127.0.0.1:5432/bench

В файле теста остаётся только dsn: "${{ vars.db_dsn }}". DSN никогда не печатается в лог, а сырой SQL не попадает в метрики — лейблами служат только имена шагов.

3. Шаги std/db-*

std/db-connect@v1 — открывает подключение и называет его через outputs:

  • driver (обязательный) — postgres, mysql (покрывает MariaDB) или sqlite.
  • dsn (обязательный) — строка подключения в формате драйвера; интерполируется, поэтому ${{ vars.db_dsn }} держит секрет вне файла.
  • tlstrue (по умолчанию), false или "skip-verify"; для SQLite игнорируется.
  • modepersistent (по умолчанию) паркует пул, которым пользуются следующие шаги; per-query лишь сохраняет конфиг подключения, и каждый запрос открывает свежее соединение, так что connect + query измеряются вместе. Транзакции на per-query-подключениях не разрешены.
  • pool_size — размер пула в persistent-режиме, по умолчанию 1.
  • timeout_ms — таймаут подключения, по умолчанию 30000.

std/db-query@v1 — выполняет один параметризованный стейтмент на подключении:

  • id (обязательный) — подключение, обычно "${{ conn.id }}".
  • query — SQL с плейсхолдерами драйвера: $1, $2, … для PostgreSQL (который отвергает ?), ? для MySQL/MariaDB и SQLite. Текст SQL никогда не интерполируется — значения передаются через params.
  • params — позиционные значения биндинга; каждый элемент может интерполироваться ("${{ vars.uid }}"). Биндятся строки, числа, булевы значения и null; массивы и объекты отклоняются.
  • max_rows — защитный лимит на строки, читаемые в память, по умолчанию 10000; при достижении лимита выход лишь получает truncated: true.
  • timeout_ms — таймаут запроса (в per-query-режиме: connect + query), по умолчанию 30000.

INSERT, UPDATE, DELETE и DDL разрешены по умолчанию. Выход содержит rows, rows_affected, truncated, data (строки как JSON-объекты) и duration_ms.

std/db-tx-begin@v1 / std/db-tx-commit@v1 / std/db-tx-rollback@v1 — начало, коммит или откат транзакции на persistent-подключении (параметр id, плюс опциональный timeout_ms). Запросы между begin и commit идут внутри транзакции, и вся единица попадает в db_query_duration.

std/db-close@v1 — параметр id; закрывает подключение и освобождает его id. Всё, что сценарий оставил открытым, сбрасывается в конце итерации.

Заметка про SQLite in-memory: sqlite::memory: требует mode: persistent и pool_size: 1 (это и есть дефолты) — per-query-подключение или второе соединение в пуле оказались бы другой, пустой базой.

4. Напишите тест

db.test.yaml — подключение, транзакция (create, insert с интерполированными bind-параметрами, select с проверкой латенси), коммит, закрытие. Каждый VU крутит этот список, пока не истечёт длительность, так что подключение и транзакция оплачиваются раз на итерацию:

steps:
  - name: connect
    uses: std/db-connect@v1
    with:
      driver: postgres
      dsn: "${{ vars.db_dsn }}"
      mode: persistent
    outputs: conn

  - name: begin tx
    uses: std/db-tx-begin@v1
    with: { id: "${{ conn.id }}" }

  - name: create bench table
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: CREATE TABLE IF NOT EXISTS bench (id TEXT, payload TEXT)

  - name: insert row
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      # Текст SQL никогда не интерполируется — значения идут через `params`.
      query: INSERT INTO bench (id, payload) VALUES ($1, $2)
      params: ["${{ vars.uid }}", "payload"]

  - name: read it back
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: SELECT payload FROM bench WHERE id = $1
      params: ["${{ vars.uid }}"]
    check:
      duration_ms_lt: 50

  - name: commit
    uses: std/db-tx-commit@v1
    with: { id: "${{ conn.id }}" }

  - name: close
    uses: std/db-close@v1
    with: { id: "${{ conn.id }}" }

db.config.yaml — профиль нагрузки плюс переменные из раздела 2:

vus: 5
duration: 30s
variables:
  db_dsn: postgres://bench:secret@127.0.0.1:5432/bench
  uid: user-1

Нет сервера под рукой? Тот же сценарий работает на SQLite как есть — поменяйте шаг подключения и используйте плейсхолдеры ? (sqlite::memory: остаётся на дефолтах mode: persistent и pool_size: 1, см. заметку выше):

steps:
  - name: connect
    uses: std/db-connect@v1
    with:
      driver: sqlite
      dsn: "sqlite::memory:"
    outputs: conn

  - name: begin tx
    uses: std/db-tx-begin@v1
    with: { id: "${{ conn.id }}" }

  - name: create bench table
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: CREATE TABLE bench (id TEXT, payload TEXT)

  - name: insert row
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: INSERT INTO bench (id, payload) VALUES (?, ?)
      params: ["${{ vars.uid }}", "payload"]

  - name: read it back
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: SELECT payload FROM bench WHERE id = ?
      params: ["${{ vars.uid }}"]
    check:
      duration_ms_lt: 50

  - name: commit
    uses: std/db-tx-commit@v1
    with: { id: "${{ conn.id }}" }

  - name: close
    uses: std/db-close@v1
    with: { id: "${{ conn.id }}" }

5. Запустите

perfscale run -f db.test.yaml -c db.config.yaml

-f выбирает встроенный нативный движок и требует -c. Проверить файлы без запуска:

perfscale lint db.test.yaml db.config.yaml

6. Читаем вывод

Построчный вывод по запросам стримится в stdout, затем печатается k6-совместимая сводка. Шаги баз данных никогда не попадают в серию http_req_*, поэтому сводка чистого database-прогона состоит только из строк db_*:

vus....................: 5 min=1 max=5
iterations..............: 140 4.67/s
db_rows: 280 9.33/s
db_connect_duration: avg=1.85ms p(50)=1.76ms p(90)=2.40ms p(95)=2.72ms p(99)=3.60ms min=1.12ms max=4.35ms count=140
db_query_duration: avg=0.42ms p(50)=0.38ms p(90)=0.61ms p(95)=0.74ms p(99)=1.10ms min=0.18ms max=1.90ms count=700
  • db_connect_duration — время открытия подключения (только успешные std/db-connect@v1; per-query-режим вместо этого включает connect в каждый запрос).
  • db_query_duration — каждый std/db-query@v1 и транзакционный шаг; в per-query-режиме включает свежий connect.
  • db_rows — строки, возвращённые запросом, либо затронутые строки, если стейтмент не вернул строк.
  • db_errors — каждый упавший DB-шаг (строка печатается, только когда были ошибки), плюс по классифицированному счётчику на класс ошибки: db_errors_connection, db_errors_constraint, db_errors_deadlock, db_errors_timeout, db_errors_other.

Прогон завершается с кодом 0, даже если проверки падали, — упавшие проверки это обратная связь нагрузочного теста, видимая в сводке, а не ошибка CLI. Учтите, что --summary-export парсит только семейство http_req_*, поэтому чистый database-прогон экспортирует summary: null — для CI-гейта проверяйте строки db_* в stdout (или добавьте в сценарий HTTP-шаг).

7. Примеры

Готовые заготовки под типовые сценарии — копируйте и запускайте. Каждый пример сочетает урезанный *.test.yaml с нужными строками конфига; все блоки ниже проходят perfscale lint в том виде, в каком приведены.

Аналитика на чтение (PostgreSQL)

SELECT-трафик отчётности через persistent-пул, припаркованный на всю итерацию. max_rows ограничивает, сколько строк runaway-результат может втянуть в память (при достижении лимита выход лишь получает truncated: true), а check превращает ваш SLO по латенси в видимый фейл в сводке. pool_size: 4 — это запас, а не параллелизм: шаги одного VU выполняются строго последовательно. Строковые бинды на PostgreSQL имеют тип text, поэтому кастуйте в SQL ($1::timestamptz):

# analytics.test.yaml
steps:
  - name: connect
    uses: std/db-connect@v1
    with:
      driver: postgres
      dsn: "${{ vars.db_dsn }}"
      mode: persistent
      pool_size: 4
    outputs: conn

  - name: orders window
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: SELECT id, total FROM orders WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz
      params: ["${{ vars.from_ts }}", "${{ vars.to_ts }}"]
      max_rows: 500
    check:
      duration_ms_lt: 200

  - name: close
    uses: std/db-close@v1
    with: { id: "${{ conn.id }}" }
# analytics.config.yaml
vus: 20
duration: 5m
variables:
  db_dsn: postgres://bench:secret@127.0.0.1:5432/shop
  from_ts: "2026-01-01T00:00:00Z"
  to_ts: "2026-02-01T00:00:00Z"

Нагрузка на вставку (PostgreSQL)

Один INSERT на итерацию, значения идут через params — сам текст SQL никогда не интерполируется. Уникальность должна откуда-то браться: в bind-параметрах нет токена с номером итерации, поэтому ключ пусть чеканит база (здесь — identity-колонка, либо gen_random_uuid()) — или биндьте значение, которое произвёл более ранний шаг. rows_affected в выходе подтверждает запись:

# подключение — как в предыдущем примере, дальше на каждой итерации:
  - name: insert event
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      # id — GENERATED ALWAYS AS IDENTITY: база чеканит уникальный ключ
      # на каждое выполнение; в bind-параметрах токена итерации нет.
      query: INSERT INTO events (tenant, kind) VALUES ($1, $2)
      params: ["${{ vars.tenant }}", "click"]
    outputs: ins

  - name: confirm write
    uses: std/log@v1
    with:
      message: "rows_affected=${{ ins.rows_affected }}"
  # Натуральные ключи вместо суррогатного id? Следите за
  # `db_errors_constraint` в сводке — растущий счётчик это фейлы
  # unique-ограничения.
# insert.config.yaml
vus: 10
duration: 2m
variables:
  db_dsn: postgres://bench:secret@127.0.0.1:5432/shop
  tenant: acme

Денежный перевод в одной транзакции

Begin, debit, credit, commit — оба UPDATE идут внутри одной транзакции на persistent-подключении, и каждый шаг (begin/запросы/commit) попадает в db_query_duration. Суммы и id счетов передаются биндами, а не склеиваются в SQL. Два факта: шаги выполняются безусловно (упавший шаг не останавливает список), а открытая транзакция откатывается, когда подключение сбрасывается в конце итерации. Поэтому коммит — последним, а на ветке ошибки замените его явным rollback — показан закомментированным:

  - name: begin transfer
    uses: std/db-tx-begin@v1
    with: { id: "${{ conn.id }}" }

  - name: debit sender
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: UPDATE accounts SET balance = balance - $1 WHERE id = $2
      params: ["${{ vars.amount }}", "${{ vars.from_id }}"]

  - name: credit receiver
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: UPDATE accounts SET balance = balance + $1 WHERE id = $2
      params: ["${{ vars.amount }}", "${{ vars.to_id }}"]

  - name: commit transfer
    uses: std/db-tx-commit@v1
    with: { id: "${{ conn.id }}" }

  # Ветка ошибки — замените коммит на:
  #   - name: rollback transfer
  #     uses: std/db-tx-rollback@v1
  #     with: { id: "${{ conn.id }}" }

На PostgreSQL упавший стейтмент абортит транзакцию, так что финальный COMMIT откатится, а не закоммитит половину перевода; на MySQL/MariaDB упавший стейтмент транзакцию не портит — там rollback делайте явно.

Serverless / режим per-query

mode: per-query лишь сохраняет конфиг подключения и открывает свежее соединение на каждый запрос — форма serverless/edge-драйверов и бенчмарков времени подключения. db_query_duration в этом режиме измеряет connect + query как одно целое, сам шаг подключения не делает сетевого I/O, а транзакции на per-query id отклоняются. Обратите внимание на таймаут: в этом режиме timeout_ms шага запроса покрывает connect + query вместе. std/db-close@v1 здесь — no-op (профили просто сбрасываются), поэтому в примере его нет:

steps:
  - name: store connect config
    uses: std/db-connect@v1
    with:
      driver: postgres
      dsn: "${{ vars.db_dsn }}"
      mode: per-query
    outputs: pq

  - name: one-shot lookup
    uses: std/db-query@v1
    with:
      id: "${{ pq.id }}"
      query: SELECT plan FROM subscriptions WHERE id = $1
      params: ["${{ vars.sub_id }}"]
      timeout_ms: 500        # per-query-режим: покрывает connect + query
    check:
      duration_ms_lt: 250

Вариант для MySQL / MariaDB

Тот же аналитический пример с двумя изменениями: driver: mysql (покрывает MariaDB) с DSN mysql:// и плейсхолдеры ?. Стиль плейсхолдеров — единственное синтаксическое различие между драйверами: PostgreSQL требует нумерованные $1, $2, … и отвергает ?; MySQL/MariaDB и SQLite используют ? и не знают $n. tls: "skip-verify" шифрует без проверки сертификата — только для стейджинга с самоподписанными сертификатами:

  - name: connect
    uses: std/db-connect@v1
    with:
      driver: mysql                    # покрывает MariaDB
      dsn: "${{ vars.mysql_dsn }}"     # mysql://user:pass@host:3306/db
      tls: "skip-verify"               # только стейджинг
    outputs: conn

  - name: orders window
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      # Плейсхолдеры ?, а не $1 — PostgreSQL тут особенный ($1, $2, …).
      query: SELECT id, total FROM orders WHERE created_at >= ? AND created_at < ?
      params: ["${{ vars.from_ts }}", "${{ vars.to_ts }}"]
      max_rows: 500
    check:
      duration_ms_lt: 200

SQLite без установки

Никакого сервера — самый быстрый способ попробовать db-шаги. Две ловушки: файловому DSN нужен ?mode=rwc, иначе первый коннект упадёт, если файла ещё нет, а сам DSN нужно брать в кавычки — : и ? значимы в YAML. Для sqlite::memory: оставайтесь на дефолтах (mode: persistent, pool_size: 1) — per-query-подключение или второе соединение в пуле оказались бы другой, пустой базой:

steps:
  - name: connect
    uses: std/db-connect@v1
    with:
      driver: sqlite
      dsn: "sqlite:///tmp/bench.db?mode=rwc"   # mode=rwc создаёт файл
    outputs: conn

  - name: create table
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: CREATE TABLE IF NOT EXISTS bench (id TEXT, payload TEXT)

  - name: insert row
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: INSERT INTO bench (id, payload) VALUES (?, ?)
      params: ["${{ vars.uid }}", "payload"]

  # … чтение обратно, закрытие — как в разделе 4 …

HTTP API + проверка в базе

Движки std-тира сочетаются в одном сценарии: нагружаете API по HTTP, затем проверяете, что строка легла в базу, — та же итерация, одна сводка (строки http_req_* и db_* рядом). Проверка числа строк спускается в выход запроса через on: (data.0 — первая строка), а message_matches делает JSON-subset-матч по счётчику:

steps:
  - name: create order via API
    uses: std/http@v1
    with:
      method: POST
      url: "${{ vars.api_url }}/orders"
      headers: { content-type: application/json }
      body: '{"sku": "SKU-1", "qty": 1}'
    check:
      status: 201

  - name: connect
    uses: std/db-connect@v1
    with:
      driver: postgres
      dsn: "${{ vars.db_dsn }}"
    outputs: conn

  - name: order persisted?
    uses: std/db-query@v1
    with:
      id: "${{ conn.id }}"
      query: SELECT count(*) AS n FROM orders WHERE sku = $1
      params: ["SKU-1"]
    outputs: cnt
    check:
      on: cnt.data.0              # первая строка результата
      message_matches: { n: 1 }   # subset-матч по { "n": 1 }

  - name: close
    uses: std/db-close@v1
    with: { id: "${{ conn.id }}" }
# mixed.config.yaml
vus: 5
duration: 30s
variables:
  api_url: http://127.0.0.1:8080
  db_dsn: postgres://bench:secret@127.0.0.1:5432/shop

Ступенчатая нагрузка (разгон → плато → спад)

Профиль нагрузки в CLI — плоский vus × duration: в open-source-движке нет фаз внутри прогона (именованные пресеты вроде load живут в Конфигурациях платформы, которые запускают этот же файл теста). Чтобы сделать ступеньки из CLI, прогоните фазы подряд: один тест, три конфига, отличающихся только профилем нагрузки. Каждая фаза печатает свою сводку — сравните перцентили db_query_duration между фазами, чтобы найти точку, где база начинает сдавать:

# phase-up.config.yaml
vus: 5
duration: 2m
variables:
  db_dsn: postgres://bench:secret@127.0.0.1:5432/shop
# phase-plateau.config.yaml — тот же файл, vus: 25, duration: 5m
# phase-down.config.yaml    — тот же файл, vus: 10, duration: 1m
for phase in up plateau down; do
  perfscale run -f analytics.test.yaml -c "phase-$phase.config.yaml" --quiet
done

Как мне…?

ЗадачаЧто использовать
Измерить латенси подключенияmode: per-query — connect + query попадают в один сэмпл db_query_duration
Тестировать транзакциюstd/db-tx-begin@v1 → запросы → std/db-tx-commit@v1 (или std/db-tx-rollback@v1)
Избежать SQL-инъекцийВсе значения — только через params: текст SQL никогда не интерполируется
Тест без сервераSQLite-файл (sqlite:///tmp/x.db?mode=rwc) или sqlite::memory:
Самоподписанный TLS-сертификатtls: "skip-verify" в шаге подключения (false = открытый текст)
Ограничить память на больших SELECTmax_rows в std/db-query@v1
Проверять результаты запросаcheck с on: out.data.0 + message_matches
Замедлить темп одного VUstd/sleep@v1 между шагами

Дальше