Учебник веб-разработки
Разделы учебника
На этой странице

VIII. Системы хранения

Лаборатории 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.