Лаборатории SQL, JSONB, Redis и TypeORM
Используем вымышленные публикации и авторов. PostgreSQL 17 — отдельная пустая
учебная база; Redis — отдельный экземпляр без данных CMS. Выполняйте SQL через
psql с ON_ERROR_STOP=1. Имя подключения берите из своей учебной среды,
пароль не вставляйте в команды и отчёт. Никакие таблицы Strapi не изменяются.
Эта глава содержит выполняемые инструкции; готовность текста не означает,
что внешние сервисы уже запущены и проверены на вашей машине.
SQL: связи, нормализация и JSONB
Исходник схемы
erDiagram
AUTHOR ||--o{ PUBLICATION : writes
AUTHOR {
int id PK
text name
}
PUBLICATION {
int id PK
int author_id FK
text title
jsonb attributes
}
Один автор имеет ноль или больше публикаций, у публикации ровно один автор. Кардинальность соответствует NOT NULL foreign key, а не только рисунку.
CREATE SCHEMA lesson_data;
CREATE TABLE lesson_data.author (
id integer PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE lesson_data.publication (
id integer PRIMARY KEY,
author_id integer NOT NULL REFERENCES lesson_data.author(id),
title text NOT NULL CHECK (length(btrim(title)) > 0),
attributes jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(attributes) = 'object')
);
INSERT INTO lesson_data.author VALUES (1, 'Анна'), (2, 'Борис'), (3, 'Вера');
INSERT INTO lesson_data.publication VALUES
(1, 1, 'Первый урок', '{"level":"beginner","duration":60}'),
(2, 1, 'Второй урок', '{"level":"advanced","duration":90}'),
(3, 2, 'Открытая встреча', '{"level":"beginner"}');
SELECT a.id, a.name, count(p.id) AS publications
FROM lesson_data.author a LEFT JOIN lesson_data.publication p ON p.author_id = a.id
GROUP BY a.id, a.name ORDER BY a.id;
SELECT id, title FROM lesson_data.publication
WHERE attributes @> '{"level":"beginner"}'::jsonb ORDER BY id;
CREATE INDEX publication_author ON lesson_data.publication(author_id);
CREATE INDEX publication_attributes ON lesson_data.publication USING gin(attributes);
ANALYZE lesson_data.publication;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM lesson_data.publication
WHERE attributes @> '{"level":"beginner"}'::jsonb;
Ожидаем авторов с counts 2, 1, 0 и beginner IDs 1, 3. count(*) после LEFT JOIN
дал бы 1 для автора без публикаций; считайте nullable поле связанной таблицы.
Имя автора хранится отдельно, чтобы переименование не обновляло каждую публикацию.
JSONB подходит для гибких attributes; связь и обязательные инварианты не прячем
в документ без причины. CHECK здесь проверяет только object, не схему duration.
На трёх строках Seq Scan нормален: индекс не обязан быть дешевле. Для опыта с планом создайте большой учебный набор, сохраните распределение значений, выполните ANALYZE и сравните число прочитанных строк/блоков. Не отключайте Seq Scan, чтобы «доказать» пользу индекса. EXPLAIN ANALYZE реально выполняет запрос, поэтому для UPDATE учитывайте эффект и транзакцию.
Приёмка: чужой author_id отвергается FK, массив attributes отвергается CHECK,
переименование автора отражается в JOIN без изменения публикаций. Проверьте
missing duration, JSON null и SQL NULL отдельно. Выберите колонку для значения,
по которому часто фильтруете и которое обязано проходить ограничения.
Redis: TTL и отказ кеша
В отдельном учебном Redis через redis-cli, с префиксом lesson::
SET lesson:publication:1 "old" EX 60
GET lesson:publication:1
TTL lesson:publication:1
DEL lesson:publication:1
GET lesson:publication:1
SET lesson:lock:publication:1 "owner-A" NX PX 5000
SET lesson:lock:publication:1 "owner-B" NX PX 5000
Сначала old, затем TTL не больше 60, после DEL nil; второй NX не захватит занятый lock. После истечения аренды B может получить lock, но A всё ещё способен выполнять старую работу. Для release проверяйте owner атомарно:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
Передайте скрипт через EVAL с одним ключом и owner value. Отдельный GET, затем DEL оставляет гонку; удалять чужую аренду нельзя. Для защиты записи одного owner value недостаточно: нужен fencing/version на стороне хранилища, если старый владелец опасен. Это учебный single-instance lock, не доказательство распределённой безопасности.
Для cache-aside сначала читайте кеш, на miss — источник и сохраняйте с TTL. Запись источника меняет данные и инвалидирует ключ; возможные гонки разобраны в лаборатории кеша. Отключите только свой учебный Redis и проверьте чтение из БД с ограниченным timeout: отказ кеша не должен превращать существующую публикацию в 404. Сравните задержку и нагрузку на источник. Если Redis хранит sessions/очередь/единственные данные, требования и политика отказа другие. TTL и persist-настройка — независимые свойства.
Не используйте FLUSHALL/FLUSHDB. Очистите только перечисленные lesson: ключи
после опыта. В Atmanki Redis не установлен и не нужен для этого чтения CMS.
TypeORM: Data Mapper и реальные запросы
Во временном каталоге вне workspace установите typeorm@0.3.28, pg@8.16.3
и reflect-metadata@0.2.2 с точными версиями. Это отдельный учебный проект.
Используем EntitySchema, чтобы не добавлять transpiler/decorators ради опыта.
Сохраните блок в orm.mjs; LESSON_DATABASE_URL указывает только на учебную БД.
Запуск: node orm.mjs. Таблицы создаются миграцией, не synchronize: true.
import "reflect-metadata";
import assert from "node:assert/strict";
import { DataSource, EntitySchema } from "typeorm";
const Author = new EntitySchema({
name: "Author",
schema: "lesson_orm",
tableName: "author",
columns: { id: { type: Number, primary: true }, name: { type: String } },
relations: {
publications: { type: "one-to-many", target: "Publication", inverseSide: "author" },
},
});
const Publication = new EntitySchema({
name: "Publication",
schema: "lesson_orm",
tableName: "publication",
columns: {
id: { type: Number, primary: true },
title: { type: String },
version: { type: Number, default: 1 },
},
relations: {
author: {
type: "many-to-one",
target: "Author",
joinColumn: { name: "author_id" },
nullable: false,
},
},
});
class Initial1700000000000 {
async up(queryRunner) {
await queryRunner.query("CREATE SCHEMA lesson_orm");
await queryRunner.query(
"CREATE TABLE lesson_orm.author (id integer PRIMARY KEY, name text NOT NULL)",
);
await queryRunner.query(
"CREATE TABLE lesson_orm.publication (id integer PRIMARY KEY, title text NOT NULL CHECK(length(btrim(title))>0), version integer NOT NULL DEFAULT 1 CHECK(version>0), author_id integer NOT NULL REFERENCES lesson_orm.author(id))",
);
}
async down(queryRunner) {
await queryRunner.query("DROP SCHEMA lesson_orm CASCADE");
}
}
if (!process.env.LESSON_DATABASE_URL) throw new Error("Set a separate learning database URL");
const db = new DataSource({
type: "postgres",
url: process.env.LESSON_DATABASE_URL,
entities: [Author, Publication],
migrations: [Initial1700000000000],
migrationsTableName: "lesson_orm_migrations",
synchronize: false,
logging: ["query", "error"],
});
await db.initialize();
try {
await db.runMigrations();
await db.transaction(async (manager) => {
await manager.getRepository("Author").save({ id: 1, name: "Анна" });
await manager.getRepository("Publication").save({ id: 1, title: "Урок", author: { id: 1 } });
});
const rows = await db.getRepository("Publication").find({ relations: { author: true } });
assert.equal(rows[0].author.name, "Анна");
const result = await db
.getRepository("Publication")
.createQueryBuilder()
.update()
.set({ title: "Новый урок", version: () => "version + 1" })
.where("id = :id AND version = :version", { id: 1, version: 1 })
.execute();
assert.equal(result.affected, 1);
const stale = await db
.getRepository("Publication")
.createQueryBuilder()
.update()
.set({ title: "Старая правка", version: () => "version + 1" })
.where("id = :id AND version = :version", { id: 1, version: 1 })
.execute();
assert.equal(stale.affected, 0);
} finally {
await db.destroy();
}
Первый запуск предполагает чистую схему; повтор на тех же данных изменит исходные условия version. Миграцию down запускайте только для этого отдельного опыта после проверки списка объектов; CASCADE не является командой очистки CMS. Логи SQL на вымышленных данных помогают увидеть запрос, но production query logging может раскрыть параметры.
Data Mapper использует repository/manager отдельно от entities. В Active Record
entity наследует BaseEntity и получает save/find методы. Это разные способы
расположить ответственность, не разные гарантии транзакций. Внутри transaction
используйте её manager, а не глобальный repository: иначе запрос может уйти
через другое соединение за пределами транзакции.
Пул ограничивает параллельные соединения; сумма пулов всех процессов должна
укладываться в бюджет БД. QueryBuilder не делает произвольную строку SQL безопасной:
значения передавайте параметрами, имена сортировки выбирайте из allowlist.
VersionColumn или save сами по себе не доказывают нужную проверку конфликта;
здесь ожидаемую version проверяет WHERE и число затронутых строк.
Опыт N+1: создайте 10 авторов и публикации. Сначала загрузите publications,
затем отдельным read автора для каждого элемента; посчитайте SQL. Сравните
один JOIN и batch IN на уникальных IDs. Меньше запросов не всегда быстрее:
избыточный JOIN больших коллекций умножает строки. Покажите SQL и план.
Приёмка: миграция на пустой БД, FK, одно условное обновление и один конфликт, rollback при ошибке внутри transaction, число запросов без/со связями и закрытие пула. Проверка синтаксиса JS не заменяет запуск против PostgreSQL 17.
Источники: JOIN, JSONB, Redis SET, EntitySchema, transactions.