Доступ к базе данных
node.pg даёт ресурсу асинхронный доступ к PostgreSQL хоста: параметризованные операторы,
транзакции на одном зарезервированном соединении, пул соединений и ошибки в виде таблиц с SQLSTATE
внутри. Рабочий поток ничего не ждёт - оператор выполняется в потоке базы данных, а его исход
возвращается колбэком или возобновляет вашу корутину. Хост задаёт одну строку подключения в
server.toml; пул общий для всех ресурсов. Эта страница - руководство; в
справочнике Lua API есть сигнатура каждого вызова, а в
справочнике C ABI - записи pg_* для нативных модулей.
node.storage или node.pg
Заголовок раздела «node.storage или node.pg»Оба хранят данные; нужды у них разные.
node.storage |
node.pg |
|
|---|---|---|
| Где | JSON-файл на ресурс в storage/, целиком в памяти |
PostgreSQL хоста |
| Настройка | не нужна | хост устанавливает PostgreSQL и задаёт [Database] Url |
| Чтение | синхронное, из памяти | асинхронное: колбэк или приостановленная корутина |
| Форма | ключ - значение | таблицы, индексы, WHERE, ORDER BY, SUM |
| Несколько записей как одна | нет | node.pg.tx |
| Общий доступ | один ресурс | все ресурсы сервера, другие серверы, ваши собственные инструменты |
| Размер | сколько помещается в память | сколько помещается на диск базы данных |
Используйте node.storage для настроек, счётчиков и отметок «последний раз видели». Используйте
node.pg, когда данные делят несколько ресурсов или несколько серверов, когда вы их запрашиваете
(десятка лучших, сумма, фильтр), когда деньгам или инвентарю нужна транзакция или когда данные
перерастают память. Ресурс, который работает и на серверах без базы данных, один раз при загрузке
проверяет node.pg.enabled() и работает в урезанном режиме: иначе каждый вызов node.pg отвечает
pg_disabled.
Настройка базы данных
Заголовок раздела «Настройка базы данных»Эта часть - для хоста. Серверу нужны роль PostgreSQL, база данных, в которой он может создавать таблицы, и строка подключения.
Debian и Ubuntu
sudo apt install -y postgresqlsudo -u postgres psql -c "CREATE ROLE nodemp LOGIN PASSWORD 'change-me';" \ -c "CREATE DATABASE nodemp OWNER nodemp;"Docker
docker run -d --name nodemp-postgres --restart unless-stopped \ -e POSTGRES_USER=nodemp -e POSTGRES_PASSWORD=change-me -e POSTGRES_DB=nodemp \ -v "$PWD/pgdata:/var/lib/postgresql/data" \ -p 127.0.0.1:5432:5432 \ postgres:16-alpineОбраз создаёт роль и базу данных из этих трёх переменных. Когда сервер тоже работает под Docker,
поместите оба контейнера в одну сеть compose и используйте имя сервиса как хост
(postgres://nodemp:change-me@postgres:5432/nodemp).
Роль владеет своей базой данных и ничем больше: она может создавать в ней таблицы и не имеет прав
на таблицы других баз данных. Никогда не отдавайте серверу
суперпользователя. Чтобы разделить существующую базу данных с другими приложениями, дайте серверу
собственную схему: CREATE SCHEMA nodemp AUTHORIZATION nodemp; и
ALTER ROLE nodemp SET search_path = nodemp;.
Затем укажите базу данных в server.toml - или в NODE_DATABASE_URL, где секрету и место в
контейнере, - и перезапустите сервер:
[Database]Для базы данных на другой машине PostgreSQL должен слушать этот интерфейс (listen_addresses в
postgresql.conf) и разрешать адрес сервера в pg_hba.conf; добавьте к Url ?sslmode=require,
чтобы пароль и данные не шли по сети открытым текстом. Остальные ключи [Database] - размер пула,
тайм-ауты, предел строк - описаны на странице Конфигурация.
Консоль подтверждает подключение строкой
pg: database reachable (postgres://nodemp:***@127.0.0.1:5432/nodemp), server 160009. Пароль
заменён на *** в каждой строке лога, в написаниях ?password= и password= тоже. База
данных, недоступная при запуске, не фатальна: сервер пишет
pg: cannot connect to postgres://nodemp:***@127.0.0.1:5432/nodemp: … (retrying in the background, 0.5 s to 30 s)
и продолжает попытки; node.pg.ready() становится true, когда подключение удаётся, а до этого
каждый оператор сразу завершается с 08001.
Запросы: query и exec
Заголовок раздела «Запросы: query и exec»node.pg.query(sql, params?, cb?) выполняет один оператор и отдаёт его строки;
node.pg.exec(sql, params?, cb?) выполняет один оператор и отдаёт только счётчик - строки,
затронутые INSERT, UPDATE или DELETE, или строки, которые вернул бы SELECT, - не
материализуя ни одной. Значения передаются параметрами $1..$n и никогда не склеиваются с текстом.
С колбэком, из любого обработчика - таблица wallets здесь та, которую создаёт
миграция ниже:
node.on("playerJoined", function(player) if not player.accountId then return -- a Test Drive guest has no account to key a wallet by end
node.pg.query("SELECT name, balance FROM wallets WHERE account_id = $1", { player.accountId }, function(result, err) if err then node.log.warn("wallet lookup failed: %s (%s)", err.message, err.code) return end for _, row in ipairs(result.rows) do node.log("%s has %d", row.name, row.balance) end end)
node.pg.exec("UPDATE wallets SET balance = balance + $2 WHERE account_id = $1", { player.accountId, 500 }, function(count, err) if err then node.log.warn("credit failed: %s", err.message) elseif count == 0 then node.log.warn("no wallet for account %d", player.accountId) end end)end)
-- expect-not: wallet lookup failed-- expect-not: credit failedcb(result, err) выполняется в рабочем потоке, когда оператор завершён, как и любой другой колбэк;
ошибка, выброшенная внутри него, пишется в лог как wallet · error in pg callback: …. Задан ровно
один из двух аргументов. result - это { rows, count, columns }: rows - массив таблиц с ключами
по именам колонок, count - число возвращённых (или затронутых) строк, columns - имена колонок по
порядку.
Без колбэка, внутри node.async, те же вызовы приостанавливают корутину и возвращают те же два
значения:
node.on("playerJoined", function(player) if not player.accountId then return -- a Test Drive guest has no account to key a wallet by end node.async(function() local result, err = node.pg.query( "INSERT INTO wallets (account_id, name) VALUES ($1, $2) " .. "ON CONFLICT (account_id) DO UPDATE SET name = EXCLUDED.name RETURNING balance", { player.accountId, player.name }) if not result then node.log.warn("wallet: %s", err.message) return end if player:isConnected() then -- the coroutine woke into a changed world player:tell("Balance: %d", result.rows[1].balance) end end)end)
-- expect-client: Alice chat:msg .*Balance: \d+-- expect-not: wallet:Вне корутины приостанавливающая форма не ждёт: она выбрасывает
node.pg.query: callback required outside node.async (node.pg.tx: … для транзакции). params,
который не таблица, sql, который не строка, или cb, который не функция, тоже выбрасывают ошибку
на вашей строке - это ошибки программирования, а не значения err.
Правила, которые следуют из того, как пул выполняет операторы:
- Один оператор на вызов. Операторы идут через расширенный протокол (
PQexecParams), который принимает ровно один;"UPDATE …; DELETE …"завершается с42601. Несколько операторов, которые должны пройти вместе, относятся вnode.pg.tx. - Никакого
BEGINруками.node.pg.exec("BEGIN")оставил бы общее соединение внутри транзакции тому, кому оно достанется следующим; пул сразу откатывает её и предупреждаетpg: connection 0 left in a transaction by 'BEGIN', rolling back. Используйтеnode.pg.tx. - Никакого состояния сессии.
SET(search_path,timezone, …) черезnode.pg.queryприлипает к соединению пула, которое его выполнило, и удивляет следующий оператор на нём - из любого ресурса. Вместо этого приводите тип или квалифицируйте имя прямо в операторе ($1::timestamptz,now() AT TIME ZONE 'UTC',nodemp.wallets); внутри транзакцииSET LOCALдопустим - он заканчивается вместе с ней. - Каждый оператор вне
tx- собственная транзакция: автокоммит.
Транзакции: tx
Заголовок раздела «Транзакции: tx»node.pg.tx(fn, cb?) резервирует одно соединение, выполняет BEGIN, вызывает fn(tx) как
корутину и заканчивает COMMIT, когда fn возвращает управление нормально, или ROLLBACK, когда
она выбрасывает ошибку. Внутри fn используйте tx:query(sql, params?) и tx:exec(sql, params?) -
только приостанавливающие формы, тот же контракт, что у node.pg.query и node.pg.exec без
колбэка, - и tx:rollback(reason?), который выбрасывает ошибку из fn и откатывает транзакцию. Без
cb, внутри node.async, tx приостанавливает вызывающего и возвращает то, что вернула fn, или
nil, err; с cb она выполняет fn как собственную задачу node.async и вызывает cb(...) с теми
же значениями.
-- inside node.async: tx suspends the caller and returns what fn returned, or nil, errlocal function withdraw(id, amount) return node.pg.tx(function(tx) local r, e = tx:query("SELECT balance FROM wallets WHERE account_id = $1 FOR UPDATE", { id }) if not r then error(e) -- rollback; e.code (a SQLSTATE) survives in err.cause.code end if not r.rows[1] then tx:rollback("no such wallet") end if r.rows[1].balance < amount then tx:rollback("insufficient funds") end local _, e2 = tx:exec("UPDATE wallets SET balance = balance - $2 WHERE account_id = $1", { id, amount }) if e2 then error(e2) end return r.rows[1].balance - amount end)end
node.on("playerJoined", function(player) if not player.accountId then return end node.async(function() local newBalance, err = withdraw(player.accountId, 100000) if newBalance then node.log("%s withdrew 1000.00, %d left", player.name, newBalance) else node.log("%s could not withdraw: %s (%s)", player.name, err.message, err.code) end end)end)
-- expect: Alice could not withdraw: (no such wallet|insufficient funds) \(rollback\)Что возвращается:
fn |
Соединение | Результат |
|---|---|---|
вернулась нормально, COMMIT прошёл |
COMMIT, освобождено |
возвращаемые значения fn |
вернулась нормально, COMMIT не прошёл |
откачено Postgres, освобождено | nil, err с SQLSTATE (отложенное ограничение, сбой сериализации, потерянное соединение) |
выбросила error(x) |
ROLLBACK, освобождено |
nil, { code = "rollback", message = <x, или x.message для таблицы>, cause = x } |
вызвала tx:rollback(reason) |
ROLLBACK, освобождено |
nil, { code = "rollback", message = reason } |
пережила [Database] TxTimeoutMs |
откачено сервером, освобождено | следующий tx:query/tx:exec и COMMIT отвечают { code = "tx_timeout" } |
не началась: BEGIN не прошёл |
нет | nil, err - 08001, pg_disabled, pg_queue_full или tx_timeout, если соединение не освободилось вовремя |
Правила:
- Проверяйте каждый
errвнутриfn. После неудавшегося оператора Postgres отказывает остатку транзакции с25P02, пока её не откатят;error(e)илиtx:rollback()при первой неудаче - правильный ответ.assert(tx:exec(...))делает это одним словом:execпри неудаче возвращаетnil, err, аassertвыбрасывает таблицуerr, так что SQLSTATE приходит вerr.cause.code. - В транзакции только вызовы
tx:. Обычныйnode.pg.queryвнутриfnуходит на другое соединение, вне транзакции, и не видит её незафиксированных строк. Его приостанавливающая форма к тому же ждёт второго свободного соединения: приPool = 1его нет, покаfnдержит единственное, и ожидание длится доtx_timeoutтранзакции. - Никакого
COMMITилиROLLBACKруками.tx:exec("COMMIT")заканчивает транзакцию под пулом: пул предупреждаетpg: transaction 3 was ended by a statement run inside it ('COMMIT'); the handle is finished, дескриптор мёртв, а следующийtx:query, как иCOMMIT, отправленный за вас, отвечают25P01. Возвращайтесь изfn, чтобы зафиксировать, выбрасывайте ошибку, чтобы откатить. - Коротко. Пока выполняется
fn, соединение недоступно всем остальным;node.sleepилиnode.http.fetchвнутриfnтоже его держат, аTxTimeoutMs(по умолчанию 30 с) - жёсткий предел. Прочитайте нужное, решите, запишите, вернитесь. - Дескриптор после тайм-аута продолжает отвечать. После того как сервер откатил транзакцию,
каждый вызов через
txполучаетtx_timeout, покаfnне закончится;txсообщает его один раз как исход. - Перезагрузка откатывает. Выгрузка ресурса откатывает его открытые транзакции и сбрасывает их
завершения, включая уже поставленный в очередь
COMMIT;fnне возобновляется. Лог отмечаетwallet: 1 open transaction rolled back at unloadна уровне отладки.
Параметры и типы результата
Заголовок раздела «Параметры и типы результата»Параметры ($1..$n, текстовый формат; тип выводит сервер):
| Lua | Postgres |
|---|---|
nil / node.pg.NULL |
NULL (сентинел нужен внутри массива параметров, где nil обрывает массив) |
boolean |
true/false |
| integer | целое (int8 вмещает) |
| float | %.17g (число с плавающей точкой) |
string |
как есть (текст; NUL внутри не поддерживается - передавайте bytea через decode($1,'hex')) |
table |
JSON через существующий LuaToJson (для json/jsonb) |
Результат - по OID колонки:
| Postgres | Lua |
|---|---|
bool |
boolean |
int2/int4/int8 |
integer (Lua 5.4, 64 бита) |
float4/float8 |
number |
numeric |
string (без потери точности; деньги храните целыми в минимальных единицах) |
text/varchar/uuid/timestamp*/date/time*/interval |
string (ISO-текст Postgres) |
json/jsonb |
string (декодируйте node.json.decode) |
bytea |
string (сырые байты, PQunescapeBytea) |
| массивы, прочее | string в текстовом виде Postgres |
NULL |
ключ отсутствует (nil) |
Строка результата - таблица имя колонки → значение; одноимённые колонки перекрывают друг друга
(используйте алиасы); порядок и имена - в result.columns.
На практике:
- NULL.
{ 1, nil, "x" }отправляет один параметр -nilобрывает массив, - и оператор с$2в нём тогда падает с SQLSTATE08P01(Postgres:bind message supplies 1 parameters, but …). Пишите{ 1, node.pg.NULL, "x" }. В обратную сторону ячейкаNULLпросто отсутствует:row.col == nil. - Приведение типов. Postgres выводит тип параметра из места, где тот используется; где не может
или ошибается - приводите:
$1::int,$2::jsonb,$3::timestamptz,decode($4, 'hex')::bytea. - jsonb. Передайте таблицу Lua, и она придёт текстом JSON (массив, когда её ключи
1..n, иначе объект; функции становятсяnull); строка, уже содержащая JSON, тоже подходит. Обратно приходит текст -node.json.decode(row.data). - Целые остаются точными до 64 бит:
int8за пределами 2^53 - целое Lua, а не float. - numeric приходит строкой (
"12.50"), чтобы ничего не потерялось; арифметику делайте в SQL или храните деньги какbigintв минимальных единицах (копейках) и форматируйте при показе. - Время. Метки времени - текст (
2026-09-14 19:24:00.123456+03); сравнивайте и преобразуйте в SQL (extract(epoch from created_at),now() - interval '7 days'), а не разбирайте в Lua. - Двоичные данные. Строка с байтом
NULне может быть параметром; кодируйте в hex иdecode($1, 'hex'). Колонкаbyteaвозвращается сырыми байтами. - Один оператор на вызов, до 1000 параметров. У оператора без параметров
paramsможно опустить:node.pg.query(sql, cb),node.pg.exec(sql).
err - это { code, message, detail?, hint?, constraint?, table? }. code - SQLSTATE, о котором
сообщил Postgres, или один из собственных кодов пула; необязательные поля есть, когда Postgres их
заполнил (constraint при 23505, например).
err.code |
Значение | Что делать |
|---|---|---|
pg_disabled |
[Database] Url пуст; драйвер выключен. |
Проверьте node.pg.enabled() при загрузке и работайте без этой возможности или скажите хосту. |
08001 |
Нет живого соединения: база данных недоступна или ещё не достигнута. Оператор, отправленный в этот момент, завершается сразу; уже ожидавший, когда пропало последнее соединение, получает её через QueryTimeoutMs. |
Пул переподключается сам; повторите позже или следите за node.pg.ready(). |
08006 |
Соединение потеряно во время выполнения оператора, или сервер останавливается. | Неизвестно, сработал ли оператор - повторяйте только через ключ идемпотентности (ниже). |
57014 |
Оператор выполнялся дольше [Database] QueryTimeoutMs (statement_timeout Postgres). |
Индекс, более узкий запрос или больший тайм-аут. |
23505 |
Нарушение уникальности; err.constraint называет индекс. |
Ожидаемо в шаблоне идемпотентности; или ON CONFLICT, когда дубликат допустим. |
42601 |
Синтаксическая ошибка - её же получает строка с двумя операторами. | Один оператор на вызов. |
25P02 |
Оператор после того, как более ранний внутри транзакции не прошёл. | Проверяйте каждый err в fn; выбрасывайте ошибку при первом. |
25P01 |
Транзакция закончена COMMIT/ROLLBACK, выполненным через дескриптор. |
Возвращайтесь из fn или выбрасывайте ошибку; никогда не заканчивайте её руками. |
0A000 |
COPY не поддерживается. |
INSERT в транзакции. |
pg_queue_full |
Уже ждут 1000 операторов. | Вы выдаёте быстрее, чем база данных отвечает: сбавьте темп, посмотрите строки о медленных запросах, увеличьте Pool. |
pg_result_cap |
Больше [Database] MaxRows строк; результат отброшен. |
LIMIT и постраничный вывод, или exec, когда нужен только счётчик. |
08P01 |
Нарушение протокола, на практике - число параметров, не совпадающее с оператором: { 1, nil, 2 } отправляет один параметр, потому что nil обрывает массив. |
node.pg.NULL для пустого места. |
pg_params |
Значение, которое драйвер не может отправить, - функция, корутина, userdata кроме node.pg.NULL, строка с NUL, таблица, которую JSON не кодирует, - или больше 1000 параметров. Сообщение - лаконичное query not queued; смотреть надо в таблицу параметров. |
Исправьте таблицу параметров. |
rollback |
fn выбросила ошибку (message, а выброшенное значение в cause) или вызвала tx:rollback(reason). |
Ваше собственное решение; SQLSTATE, который к этому привёл, - в err.cause.code. |
tx_timeout |
Транзакция пережила [Database] TxTimeoutMs и откачена, или BEGIN столько ждал свободного соединения. |
Более короткие транзакции, никаких других приостановок внутри fn, больший Pool. |
pg_reload |
Никогда не доставляется. Ресурс, выгруженный, пока оператор в полёте, никогда не видит свой колбэк, а его приостановленная корутина не возобновляется. | Проектируйте так, чтобы потерянное завершение было безвредно: правда в базе данных, перечитайте её при загрузке. |
Перезагрузка или остановка между отправкой оператора и его завершением молча сбрасывает завершение
(async completion 42 dropped: its resource was unloaded and the callback went with it на уровне
отладки) - сам оператор вполне мог выполниться. Поэтому записи, которые не должны случиться дважды,
несут ключ.
Идемпотентность через уникальный ключ
Заголовок раздела «Идемпотентность через уникальный ключ»08006, сброшенное завершение или игрок, нажавший дважды, оставляют вас в неведении, случилась ли
запись. Заставьте базу данных отказать повтору: дайте каждой записи ключ, уникальный для задуманного
действия, храните его в колонке UNIQUE и считайте 23505 на этом ограничении «уже сделано».
-- the first statement of the transfer's fn; key: made once, before the first-- attempt, and reused by every retrylocal function recordTransfer(tx, key, fromId, toId, amount) local _, err = tx:exec( "INSERT INTO ledger (idem_key, from_id, to_id, amount) VALUES ($1, $2, $3, $4)", { key, fromId, toId, amount }) if err then if err.code == "23505" and err.constraint == "ledger_idem_key_key" then tx:rollback("already applied") -- the retry of a transfer that went through end error(err) endendledger_idem_key_key - имя, которое Postgres даёт ограничению idem_key text UNIQUE; назовите его
сами (CONSTRAINT ledger_idem UNIQUE (idem_key)), если не хотите от этого зависеть. Вставка идёт
первой в транзакции, так что дубликату отказывают до того, как сдвинется какой-либо баланс.
Миграции
Заголовок раздела «Миграции»Сервер не управляет вашей схемой; ресурс создаёт и обновляет свои таблицы сам при загрузке. Две
вещи делают это безопасным, когда несколько экземпляров сервера делят одну базу данных или ресурс
перезагружается, пока другая копия ещё стартует: консультативная блокировка на время транзакции,
чтобы мигратор выполнялся только один, и таблица schema_migrations, которая записывает, до какой
версии дошёл каждый ресурс.
local MIGRATIONS = { "CREATE TABLE wallets (account_id bigint PRIMARY KEY, name text NOT NULL, balance bigint NOT NULL DEFAULT 0)", "CREATE TABLE ledger (id bigserial PRIMARY KEY, idem_key text UNIQUE NOT NULL, from_id bigint, to_id bigint, " .. "amount bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT now())",}
local function migrate() return node.pg.tx(function(tx) assert(tx:exec("SELECT pg_advisory_xact_lock(hashtext('wallet'))")) assert(tx:exec("CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, version int NOT NULL)")) local r = assert(tx:query("SELECT version FROM schema_migrations WHERE name = $1", { "wallet" })) local have = r.rows[1] and r.rows[1].version or 0 for v = have + 1, #MIGRATIONS do assert(tx:exec(MIGRATIONS[v])) end assert(tx:exec("INSERT INTO schema_migrations (name, version) VALUES ($1, $2) " .. "ON CONFLICT (name) DO UPDATE SET version = EXCLUDED.version", { "wallet", #MIGRATIONS })) return #MIGRATIONS end)endpg_advisory_xact_lock, а не pg_advisory_lock, потому что блокировка на время транзакции
снимается при COMMIT/ROLLBACK; блокировка уровня сессии осталась бы на соединении пула после
конца tx и держала бы следующий мигратор, пока это соединение не закроется. Всё выполняется в одной
транзакции, так что неудавшаяся миграция оставляет схему и версию как были, а вся пачка ограничена
TxTimeoutMs - длинное заполнение данных разбивайте на отдельные шаги. Дописывайте в MIGRATIONS,
никогда не правьте запись, которая где-то уже выполнилась.
Запускайте при загрузке, когда пул доступен, и ставьте остальной ресурс в зависимость от результата:
local schemaReady = false
node.async(function() if not node.pg.enabled() then node.log.warn("no [Database] Url: the wallet is off") return end node.wait(node.pg.ready, 500) -- the database may still be connecting at start local version, err = migrate() if not version then node.log.error("migration failed: %s (%s)", err.message, err.code) return end schemaReady = true node.log("schema at version %d", version)end)
-- expect: schema at version 2Производительность
Заголовок раздела «Производительность»- Рабочий поток никогда не ждёт, но очередь может расти. Каждый оператор ставится в очередь и
выполняется первым свободным соединением; предел - 1000 ожидающих операторов (
pg_queue_full). Запрос на каждыйserverTick(10 Гц) или на каждое обновление позиции машины переполняет её - опрашивайте по таймеру, группируйте записи, кэшируйте редко меняющиеся чтения и никогда не приостанавливайте горячий обработчик на запросе. - Размер пула.
[Database] Pool(по умолчанию 4) - число операторов в полёте одновременно, и каждаяnode.pg.txдержит одно соединение всю свою жизнь. Увеличивайте его, когда растётqueueDepthили транзакции ждутBEGIN; соединение PostgreSQL - это процесс на стороне базы данных, так что до сотен его не поднимайте. - Тайм-ауты.
QueryTimeoutMs(по умолчанию 10 с) ограничивает оператор,TxTimeoutMs(по умолчанию 30 с) - транзакцию. Оба - защита, а не бюджет: оператор, близкий к любому из них, - оператор, который нужно починить. - Медленные запросы пишутся в лог:
pg: slow query (312 ms): SELECT …для всего, что дольше 250 мс, SQL обрезан до 200 символов, параметры не пишутся никогда. Читайте их как список дел по индексам. MaxRows(по умолчанию 10 000) отбрасывает больший результат сpg_result_cap-LIMITи постраничный вывод (по ключу:WHERE id > $1 ORDER BY id LIMIT 100) илиexec, когда нужен только счётчик.- Никакого состояния сессии через
node.pg.query(SET,pg_advisory_lock, временные таблицы, подготовленные операторы): каждый оператор может попасть на другое соединение, а всё, что он оставит после себя, - проблема следующего оператора. - Метрики.
node.server.metrics().plugin.pg- это{ enabled, ready, queries, errors, p50Ms, p95Ms, queueDepth, connections, transactions, deadTransactions }: выполненные и неудавшиеся операторы, задержка по последним 512 операторам, ожидающие операторы, живые соединения, открытые транзакции и дескрипторы, которые пул закончил сам, а их владелец ещё не завершил. При выключенном драйвере блок - просто{ enabled = false }. Пишите в лог или экспортируйте по таймеру. - Чтения, которым можно быть устаревшими, живут в памяти: загрузите при старте, обновляйте по таймеру, записывайте сквозь. Обращение к базе данных на каждое сообщение чата - нормально; на каждый кадр физики - нет.
Полный пример: переводы в кошельке
Заголовок раздела «Полный пример: переводы в кошельке»Ресурс wallet с балансом на аккаунт, журналом, /balance и /pay <player> <coins>. Балансы -
bigint в копейках; перевод - одна транзакция, которая вставляет строку журнала под ключом
идемпотентности, блокирует оба кошелька в порядке идентификаторов, проверяет средства и переносит
их. Блок миграции сверху здесь - migrate().
name = "wallet"version = "1.0.0"
[server]main = "server/main.lua"-- resources/wallet/server/main.lualocal schemaReady = false
local MIGRATIONS = { "CREATE TABLE wallets (account_id bigint PRIMARY KEY, name text NOT NULL, balance bigint NOT NULL DEFAULT 0)", "CREATE TABLE ledger (id bigserial PRIMARY KEY, idem_key text UNIQUE NOT NULL, from_id bigint, to_id bigint, " .. "amount bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT now())",}
local function migrate() return node.pg.tx(function(tx) assert(tx:exec("SELECT pg_advisory_xact_lock(hashtext('wallet'))")) assert(tx:exec("CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, version int NOT NULL)")) local r = assert(tx:query("SELECT version FROM schema_migrations WHERE name = $1", { "wallet" })) local have = r.rows[1] and r.rows[1].version or 0 for v = have + 1, #MIGRATIONS do assert(tx:exec(MIGRATIONS[v])) end assert(tx:exec("INSERT INTO schema_migrations (name, version) VALUES ($1, $2) " .. "ON CONFLICT (name) DO UPDATE SET version = EXCLUDED.version", { "wallet", #MIGRATIONS })) return #MIGRATIONS end)end
local function money(cents) return string.format("%d.%02d", cents // 100, cents % 100)end
-- One transfer, one transaction. Returns the sender's new balance, or nil, err.local function transfer(key, fromId, toId, amount) return node.pg.tx(function(tx) -- 1. The ledger row first: a repeated key is refused here, before any balance moves. local _, err = tx:exec( "INSERT INTO ledger (idem_key, from_id, to_id, amount) VALUES ($1, $2, $3, $4)", { key, fromId, toId, amount }) if err then if err.code == "23505" then tx:rollback("already applied") end error(err) end -- 2. Lock both wallets, in id order, so two opposite transfers cannot deadlock. local r = assert(tx:query( "SELECT account_id, balance FROM wallets WHERE account_id IN ($1, $2) ORDER BY account_id FOR UPDATE", { fromId, toId })) if #r.rows ~= 2 then tx:rollback("no such wallet") end local balance = {} for _, row in ipairs(r.rows) do balance[row.account_id] = row.balance end if balance[fromId] < amount then tx:rollback("insufficient funds") end -- 3. Move the money. assert(tx:exec("UPDATE wallets SET balance = balance - $2 WHERE account_id = $1", { fromId, amount })) assert(tx:exec("UPDATE wallets SET balance = balance + $2 WHERE account_id = $1", { toId, amount })) return balance[fromId] - amount end)end
node.on("playerJoined", function(player) if not player.accountId or not schemaReady then return end node.pg.exec("INSERT INTO wallets (account_id, name) VALUES ($1, $2) " .. "ON CONFLICT (account_id) DO UPDATE SET name = EXCLUDED.name", { player.accountId, player.name }, function(_, err) if err then node.log.warn("wallet for %s: %s", player.name, err.message) end end)end)
node.commands.add("balance", function(player) if not player.accountId or not schemaReady then player:tell("No wallet: sign in with a NodeMP account") return end node.pg.query("SELECT balance FROM wallets WHERE account_id = $1", { player.accountId }, function(result, err) if not player:isConnected() then return end if err then player:tell("The wallet is unavailable right now") return end local row = result.rows[1] player:tell("Balance: %s", money(row and row.balance or 0)) end)end)
node.commands.add("pay", function(player, args) local target = node.players.find(args[1] or "") local coins = math.tointeger(tonumber(args[2] or "")) if not (player.accountId and schemaReady and target and target.accountId and coins and coins > 0) or target.accountId == player.accountId then player:tell("Usage: /pay <player> <coins>") return end local key = node.crypto.randomHex(8) -- one key per command, reused by the retry node.async(function() local left, err = transfer(key, player.accountId, target.accountId, coins * 100) if not left and err.code == "08006" then left, err = transfer(key, player.accountId, target.accountId, coins * 100) -- same key: safe end if not player:isConnected() then return end if left then player:tell("Sent %d coins to %s, %s left", coins, target.name, money(left)) if target:isConnected() then target:tell("%s sent you %d coins", player.name, coins) end elseif err.code == "rollback" then player:tell("Transfer refused: %s", err.message) else node.log.warn("transfer %s failed: %s (%s)", key, err.message, err.code) player:tell("Transfer failed, nothing was moved") end end)end)
node.async(function() if not node.pg.enabled() then node.log.warn("no [Database] Url: the wallet is off") return end node.wait(node.pg.ready, 500) local version, err = migrate() if not version then node.log.error("migration failed: %s (%s)", err.message, err.code) return end schemaReady = true node.log("schema at version %d", version)end)
-- expect: schema at version 2-- expect-client: Alice chat:msg .*Balance: \d+\.\d\d-- expect-client: Alice chat:msg .*(Sent 5 coins to Bob, \d+\.\d\d left|Transfer refused: insufficient funds)Что даёт такая форма: вставка в журнал идёт первой, так что повтор с тем же ключом останавливается
на 23505 до того, как что-то сдвинется, и игроку говорят «already applied», а не списывают дважды;
FOR UPDATE в фиксированном порядке выстраивает два перевода между одной парой без взаимной
блокировки; проверка баланса читает заблокированные строки, так что ни один параллельный перевод не
проскочит между чтением и записью; assert на каждом операторе превращает любую ошибку SQL в откат,
SQLSTATE которого вызывающий всё ещё может записать в лог из err.cause.code; а повтор при 08006 -
единственный повтор, потому что это единственный случай, когда исход неизвестен. Суммы - целые на
всём пути: база данных никогда не видит float, игрок никогда не видит ошибку округления.
- Справочник Lua API: node.pg - каждый вызов с сигнатурой и полный список ошибок.
- Конфигурация:
[Database]- пять ключей, которые задаёт хост. - Конкурентность - рабочий поток,
node.asyncи почему колбэки выглядят именно так. - Ресурсы -
node.storage, для данных, которым база данных не нужна.
