NoSQL — общее имя для баз, которые хранят данные не в связанных таблицах: документами (MongoDB), парами «ключ — значение» (Redis), широкими строками (Cassandra) или графом (Neo4j). Документная модель удобна, когда данные читают и меняют вместе — например, заказ с позициями; она мешает, когда между сущностями много связей, общие данные дублируются, а одна операция должна атомарно изменить несколько записей. Если почти все данные реляционные и гибкой должна быть только часть, обычно хватает колонки jsonb в PostgreSQL.
После материала вы сможете
- различить документную, key-value, колоночную и графовую модели и назвать типичную задачу для каждой
- описать заказ с позициями документом в MongoDB и строкой с
jsonbв PostgreSQL и сравнить запросы к ним - объяснить, где встраивание вредит: дубли данных, отсутствие проверки схемы, изменения в нескольких документах сразу
- выбрать хранилище по таблице и обосновать выбор
Что знать заранее
Проверено на
MongoDB 8.3 (mongo:8), mongosh 2.12PostgreSQL 17.11Redis 7.4
Сверено с документацией: 11 октября 2026 г.
Содержание
Таблицы и четыре семейства NoSQL
Реляционная база хранит данные в таблицах с заранее объявленными колонками и связывает их ключами, а запрос собирает нужное через JOIN. Словом NoSQL называют хранилища, устроенные иначе. Общего у них немного: каждое оптимизировано под свой способ доступа к данным и жертвует частью того, что реляционные СУБД дают по умолчанию, — строгой схемой, произвольными JOIN или транзакциями, которые затрагивают много записей.
Реляционная
- Как хранит данные
- Таблицы и строки, связи по ключам
- Пример
- PostgreSQL, MySQL
- Сильная сторона
- Любые запросы и
JOIN, ограничения, транзакции - Слабое место
- Изменение структуры — через миграции
Документная
- Как хранит данные
- JSON-подобные документы (в MongoDB — BSON) в коллекциях
- Пример
- MongoDB
- Сильная сторона
- Объект целиком читается одним запросом, поля у документов могут различаться
- Слабое место
- Связи между документами и дубли данных
Ключ — значение
- Как хранит данные
- Значение по уникальному ключу, в памяти
- Пример
- Redis
- Сильная сторона
- Простые и быстрые операции по ключу, срок жизни ключа (TTL)
- Слабое место
- Поиск по содержимому требует отдельных структур
Широкие строки (wide-column)
- Как хранит данные
- Таблицы, разложенные по узлам кластера по ключу раздела
- Пример
- Apache Cassandra
- Сильная сторона
- Запись и чтение по ключу на многих серверах
- Слабое место
- Нет
JOIN, таблицу проектируют под конкретный запрос
Графовая
- Как хранит данные
- Узлы, связи между ними и свойства
- Пример
- Neo4j
- Сильная сторона
- Обход связей любой глубины
- Слабое место
- Узкий профиль: остальные данные обычно живут в другой базе
Документная модель: заказ одним документом
Возьмём заказ кофейни: клиент и позиции. В реляционной базе это минимум три таблицы — заказы, позиции и клиенты. В MongoDB заказ — один документ: клиент лежит в нём вложенным объектом, позиции — массивом. Основное правило моделирования в документации MongoDB звучит так: данные, которые читают вместе, хранят вместе. Для повторения достаточно Docker: docker run -d --rm --name kb-mongo-nosql mongo:8 поднимает временный сервер, который удалится после docker stop.
// orders.mongo.js — run: mongosh shop --quiet orders.mongo.js
const show = (x) => print(JSON.stringify(x));
db.orders.drop();
db.orders.insertMany([
{
_id: 1001,
status: "paid",
customer: { id: 7, name: "Aigerim", email: "aigerim@example.com" },
items: [
{ sku: "LATTE", title: "Latte", price: 1600, qty: 2 },
{ sku: "CROISSANT", title: "Croissant", price: 1200, qty: 1 }
]
},
{
_id: 1002,
status: "new",
customer: { id: 8, name: "Timur", email: "timur@example.com" },
items: [{ sku: "LATTE", title: "Latte", price: 1600, qty: 1 }]
},
{
_id: 1003,
status: "paid",
customer: { id: 7, name: "Aigerim", email: "aigerim@example.com" },
items: [{ sku: "ESPRESSO", title: "Espresso", price: 1100, qty: 3 }]
}
]);
// 1. The whole order in one read, no JOIN
show(db.orders.findOne({ _id: 1001 }, { _id: 0, "customer.name": 1, "items.sku": 1, "items.qty": 1 }));
// 2. Orders that contain a latte (search inside an array)
show(db.orders.find({ "items.sku": "LATTE" }, { _id: 1 }).toArray());
// 3. Revenue per product in paid orders
show(db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{ $group: { _id: "$items.sku", revenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } } } },
{ $sort: { revenue: -1 } }
]).toArray());
// 4. Add an item and change the status: one atomic write to one document
show(db.orders.updateOne(
{ _id: 1002 },
{ $push: { items: { sku: "CROISSANT", title: "Croissant", price: 1200, qty: 1 } }, $set: { status: "paid" } }
));
// 5. Customer data is copied into every order: the email changed, so fix every copy
show(db.orders.updateMany({ "customer.id": 7 }, { $set: { "customer.email": "aigerim.new@example.com" } }));$ docker cp orders.mongo.js kb-mongo-nosql:/tmp/
$ docker exec kb-mongo-nosql mongosh shop --quiet /tmp/orders.mongo.js
{"customer":{"name":"Aigerim"},"items":[{"sku":"LATTE","qty":2},{"sku":"CROISSANT","qty":1}]}
[{"_id":1001},{"_id":1002}]
[{"_id":"ESPRESSO","revenue":3300},{"_id":"LATTE","revenue":3200},{"_id":"CROISSANT","revenue":1200}]
{"acknowledged":true,"insertedId":null,"matchedCount":1,"modifiedCount":1,"upsertedCount":0}
{"acknowledged":true,"insertedId":null,"matchedCount":2,"modifiedCount":2,"upsertedCount":0}Первый запрос возвращает заказ вместе с позициями без всяких JOIN. Второй ищет внутри массива: условие "items.sku": "LATTE" выполняется, если подходит хотя бы один элемент. Третий — конвейер агрегации: $unwind разворачивает массив в отдельные записи, $group суммирует. Четвёртый добавляет позицию и меняет статус одной операцией: запись в один документ в MongoDB атомарна, даже если затрагивает несколько вложенных полей, — либо применятся все изменения, либо ни одно.
Обратите внимание на цену в позиции. Это не дубль, а снимок: цена на момент заказа не должна меняться вслед за прайсом. Именно такие данные — принадлежащие родителю, ограниченные по объёму и читаемые вместе с ним — документация MongoDB советует встраивать.
Встраивать в документ
- отношение «содержит»: заказ содержит позиции
- данные читают и обновляют вместе
- вложенный массив ограничен по размеру
- данные архивируют вместе с родителем
Хранить отдельно и ссылаться
- у родителя много дочерних записей, и их число растёт без предела
- дубли сложно поддерживать актуальными
- вложенные данные часто пишут отдельно от родителя
- сущность существует сама по себе: клиент, товар
Где документ мешает: дубли, схема, транзакции
В примере клиент встроен в каждый заказ. Пока это снимок «кому доставили», всё в порядке. Но если по тем же данным работает учёт клиентов и клиент меняет email, пятой операции пришлось обновить два документа — matchedCount: 2. На реальной базе это тысячи копий, и обновлять их приходится коду приложения. В реляционной схеме email хранится в одной строке таблицы customers.
Вторая цена гибкости — схема. Коллекция по умолчанию принимает документы любой формы. Ниже цена записана строкой: вставка проходит, а ошибка всплывает позже, в отчёте.
// schema.mongo.js — run after orders.mongo.js
const show = (x) => print(JSON.stringify(x));
// Nobody checks the schema: a price stored as a string is accepted
show(db.orders.insertOne({ _id: 1004, status: "paid", items: [{ sku: "LATTE", price: "1600", qty: 1 }] }));
// ...and breaks the report later, at read time
try {
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{ $group: { _id: "$items.sku", revenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } } } }
]).toArray();
} catch (e) {
print(e.message);
}
db.orders.deleteOne({ _id: 1004 });
// A $jsonSchema validator: you switch the checks on yourself
db.runCommand({
collMod: "orders",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["status", "items"],
properties: {
status: { enum: ["new", "paid", "cancelled"] },
items: {
bsonType: "array",
minItems: 1,
items: {
bsonType: "object",
required: ["sku", "price", "qty"],
properties: {
price: { bsonType: ["int", "long", "double", "decimal"], minimum: 0 },
qty: { bsonType: "int", minimum: 1 }
}
}
}
}
}
}
});
try {
db.orders.insertOne({ _id: 1005, status: "paid", items: [{ sku: "LATTE", price: "1600", qty: 1 }] });
} catch (e) {
print(e.errmsg);
}$jsonSchema — отклоняется$ docker exec kb-mongo-nosql mongosh shop --quiet /tmp/schema.mongo.js
{"acknowledged":true,"insertedId":1004}
Executor error during aggregate command on namespace: shop.orders :: caused by :: $multiply only supports numeric types, not string
Document failed validationВалидатор $jsonSchema решает проблему, но его нужно спроектировать и поддерживать так же, как схему таблиц. В подробностях ошибки (errInfo) MongoDB указывает поле price и причину type did not match. Уже записанные документы при включении правил по умолчанию не перепроверяются: уровень strict применяет их ко всем новым вставкам и обновлениям.
Третье — транзакции. В MongoDB атомарна операция над одним документом, поэтому встраивание часто избавляет от транзакций. Транзакции через несколько документов есть — на наборах реплик и шардированных кластерах, — но документация предупреждает: обычно они дороже записи в один документ и не должны заменять продуманную схему. Если бизнес-операция по своей природе меняет несколько независимых сущностей — оплату, складской остаток, бонусный счёт, — реляционная база с транзакциями и внешними ключами решает её прямее.
Ключ — значение, широкие строки и графы
Redis хранит данные в оперативной памяти и описывает себя как сервер структур данных: кроме строк есть хеши, списки, множества, сортированные множества, потоки и другие типы. Типичные задачи — кэш, сессии, счётчики, очереди, ограничение частоты запросов. Значение достают по точному ключу, поэтому ключи проектируют под способ чтения: найти «все сессии пользователя 7» без отдельной структуры не получится. Временный сервер для примера: docker run -d --rm --name kb-redis-nosql redis:7-alpine.
$ docker exec -it kb-redis-nosql redis-cli
127.0.0.1:6379> SET session:9f2c '{"userId":7,"cart":["LATTE"]}' EX 1800
OK
127.0.0.1:6379> GET session:9f2c
"{\"userId\":7,\"cart\":[\"LATTE\"]}"
127.0.0.1:6379> TTL session:9f2c
(integer) 1800
127.0.0.1:6379> INCR views:product:LATTE
(integer) 1
127.0.0.1:6379> INCR views:product:LATTE
(integer) 2
127.0.0.1:6379> DEL session:9f2c
(integer) 1
127.0.0.1:6379> GET session:9f2c
(nil)EX 1800 задаёт срок жизни: через 30 минут ключ исчезнет сам. INCR атомарно увеличивает счётчик, даже если его одновременно вызывают сотни запросов. Как использовать Redis как кэш перед основной базой, разбираем в уроке о кэшировании.
Базы с широкими строками, например Apache Cassandra, распределяют данные по узлам кластера по ключу раздела. Документация Cassandra прямо говорит, что моделирование в ней идёт от запросов, а JOIN не поддерживаются, поэтому данные денормализуют: одну и ту же информацию кладут в несколько таблиц, каждую — под свой запрос. Это выбор для очень больших объёмов записи на многих серверах, а не для первой версии продукта.
Графовые базы, например Neo4j, хранят узлы и связи как равноправные данные: у связи есть тип, направление и свойства. Они удобны, когда главный вопрос — путь и связи: друзья друзей, рекомендации, маршруты, зависимости между объектами. Реляционная база отвечает на такие вопросы цепочкой JOIN или рекурсивным запросом, и чем глубже обход, тем сложнее запрос.
jsonb в PostgreSQL: документ внутри таблицы
В PostgreSQL для JSON есть два типа. json сохраняет текст как есть и разбирает его при каждом обращении. jsonb хранит разобранное двоичное представление: вставка чуть медленнее, зато обработка значительно быстрее, а для поиска есть индексы GIN. Документация рекомендует большинству приложений jsonb. Ниже — те же заказы: статус в обычной колонке с проверкой, остальное в документе. Сервер: docker run -d --rm --name kb-pg-nosql -e POSTGRES_PASSWORD=test postgres:17-alpine.
-- orders_jsonb.sql — run: psql -U postgres -f orders_jsonb.sql
DROP TABLE IF EXISTS orders_doc;
CREATE TABLE orders_doc (
id integer PRIMARY KEY,
status text NOT NULL CHECK (status IN ('new', 'paid', 'cancelled')),
doc jsonb NOT NULL
);
INSERT INTO orders_doc VALUES
(1001, 'paid', '{"customer": {"id": 7, "name": "Aigerim", "email": "aigerim@example.com"},
"items": [{"sku": "LATTE", "title": "Latte", "price": 1600, "qty": 2},
{"sku": "CROISSANT", "title": "Croissant", "price": 1200, "qty": 1}]}'),
(1002, 'new', '{"customer": {"id": 8, "name": "Timur", "email": "timur@example.com"},
"items": [{"sku": "LATTE", "title": "Latte", "price": 1600, "qty": 1}]}'),
(1003, 'paid', '{"customer": {"id": 7, "name": "Aigerim", "email": "aigerim@example.com"},
"items": [{"sku": "ESPRESSO", "title": "Espresso", "price": 1100, "qty": 3}]}');
-- GIN index for the containment operator @>
CREATE INDEX orders_doc_gin ON orders_doc USING GIN (doc jsonb_path_ops);
-- 1. The whole order is one row, no JOIN
SELECT doc -> 'customer' ->> 'name' AS customer,
jsonb_path_query_array(doc, '$.items[*].sku') AS skus
FROM orders_doc WHERE id = 1001;
-- 2. Orders that contain a latte
SELECT id FROM orders_doc
WHERE doc @> '{"items": [{"sku": "LATTE"}]}'
ORDER BY id;
-- 3. Revenue per product in paid orders
SELECT item ->> 'sku' AS sku,
sum((item ->> 'price')::int * (item ->> 'qty')::int) AS revenue
FROM orders_doc, jsonb_array_elements(doc -> 'items') AS item
WHERE status = 'paid'
GROUP BY 1
ORDER BY revenue DESC;
-- 4. Add an item and change the status in one UPDATE
UPDATE orders_doc
SET doc = jsonb_set(doc, '{items}',
(doc -> 'items') || '{"sku": "CROISSANT", "title": "Croissant", "price": 1200, "qty": 1}'),
status = 'paid'
WHERE id = 1002;
-- 5. Customer data is copied into every order: fix every copy
UPDATE orders_doc
SET doc = jsonb_set(doc, '{customer,email}', '"aigerim.new@example.com"')
WHERE doc @> '{"customer": {"id": 7}}';$ docker cp orders_jsonb.sql kb-pg-nosql:/tmp/
$ docker exec kb-pg-nosql psql -U postgres -f /tmp/orders_jsonb.sql
DROP TABLE
CREATE TABLE
INSERT 0 3
CREATE INDEX
customer | skus
----------+------------------------
Aigerim | ["LATTE", "CROISSANT"]
(1 row)
id
------
1001
1002
(2 rows)
sku | revenue
-----------+---------
ESPRESSO | 3300
LATTE | 3200
CROISSANT | 1200
(3 rows)
UPDATE 1
UPDATE 2Результаты совпадают с MongoDB. @> — оператор вхождения: строка подходит, если документ содержит указанный фрагмент. Класс индекса jsonb_path_ops поддерживает именно такие запросы и, по документации, обычно меньше индекса по умолчанию. Поля, по которым часто фильтруют и которые должны быть строгими, удобнее держать обычными колонками, а изменчивую часть — в jsonb.
-- json keeps the text as is, jsonb keeps the parsed value
SELECT '{"b": 1, "a": 2, "a": 3}'::json AS as_json,
'{"b": 1, "a": 2, "a": 3}'::jsonb AS as_jsonb;
-- With three rows the planner prefers a full scan; switch it off to see that the index fits
SET enable_seqscan = off;
EXPLAIN (COSTS OFF)
SELECT id FROM orders_doc WHERE doc @> '{"items": [{"sku": "LATTE"}]}';
RESET enable_seqscan;
-- jsonb does not check the structure either: a string price gets in...
INSERT INTO orders_doc VALUES (1004, 'paid', '{"items": [{"sku": "LATTE", "price": "1600", "qty": 1}]}');
DELETE FROM orders_doc WHERE id = 1004;
-- ...until you add a constraint
ALTER TABLE orders_doc ADD CONSTRAINT items_is_array
CHECK (jsonb_typeof(doc -> 'items') = 'array');
INSERT INTO orders_doc VALUES (1005, 'new', '{"items": {"sku": "LATTE"}}');$ docker exec kb-pg-nosql psql -U postgres -f /tmp/jsonb_extra.sql
as_json | as_jsonb
--------------------------+------------------
{"b": 1, "a": 2, "a": 3} | {"a": 3, "b": 1}
(1 row)
SET
QUERY PLAN
---------------------------------------------------------------------
Bitmap Heap Scan on orders_doc
Recheck Cond: (doc @> '{"items": [{"sku": "LATTE"}]}'::jsonb)
-> Bitmap Index Scan on orders_doc_gin
Index Cond: (doc @> '{"items": [{"sku": "LATTE"}]}'::jsonb)
(4 rows)
RESET
INSERT 0 1
DELETE 1
ALTER TABLE
psql:/tmp/jsonb_extra.sql:18: ERROR: new row for relation "orders_doc" violates check constraint "items_is_array"
DETAIL: Failing row contains (1005, new, {"items": {"sku": "LATTE"}}).jsonbубирает повторяющиеся ключи (остаётся последнее значение) и не хранит порядок ключей;jsonсохраняет текст без изменений.- Индекс подходит для запроса с
@>. На трёх строках планировщику выгоднее полный перебор, поэтому для демонстрации он отключён. jsonb, как и MongoDB без валидатора, примет цену строкой: структуру проверяют ограниченияCHECKили код приложения.
Документация PostgreSQL даёт два практичных совета. Держите у JSON-документов предсказуемую структуру — по ним проще писать запросы. И не раздувайте их: любое обновление блокирует строку целиком, поэтому крупные часто изменяемые документы мешают параллельным транзакциям.
-- orders_relational.sql — the same orders in tables
DROP TABLE IF EXISTS order_items, orders, products, customers;
CREATE TABLE customers (
id integer PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE products (
sku text PRIMARY KEY,
title text NOT NULL
);
CREATE TABLE orders (
id integer PRIMARY KEY,
customer_id integer NOT NULL REFERENCES customers (id),
status text NOT NULL CHECK (status IN ('new', 'paid', 'cancelled'))
);
CREATE TABLE order_items (
order_id integer NOT NULL REFERENCES orders (id),
sku text NOT NULL REFERENCES products (sku),
price integer NOT NULL CHECK (price >= 0), -- price at the time of the order
qty integer NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, sku)
);
INSERT INTO customers VALUES (7, 'Aigerim', 'aigerim@example.com'), (8, 'Timur', 'timur@example.com');
INSERT INTO products VALUES ('LATTE', 'Latte'), ('CROISSANT', 'Croissant'), ('ESPRESSO', 'Espresso');
INSERT INTO orders VALUES (1001, 7, 'paid'), (1002, 8, 'new'), (1003, 7, 'paid');
INSERT INTO order_items VALUES
(1001, 'LATTE', 1600, 2), (1001, 'CROISSANT', 1200, 1),
(1002, 'LATTE', 1600, 1),
(1003, 'ESPRESSO', 1100, 3);
-- Revenue per product: the same result, via JOIN
SELECT i.sku, sum(i.price * i.qty) AS revenue
FROM orders o JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'paid'
GROUP BY i.sku
ORDER BY revenue DESC;
-- The email lives in one place: one row
UPDATE customers SET email = 'aigerim.new@example.com' WHERE id = 7;
-- The schema rejects a string price and an item of a missing order
INSERT INTO order_items VALUES (1002, 'ESPRESSO', 'one thousand', 1);
INSERT INTO order_items VALUES (9999, 'LATTE', 1600, 1);$ docker exec kb-pg-nosql psql -U postgres -f /tmp/orders_relational.sql
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 3
INSERT 0 3
INSERT 0 4
sku | revenue
-----------+---------
ESPRESSO | 3300
LATTE | 3200
CROISSANT | 1200
(3 rows)
UPDATE 1
psql:/tmp/orders_relational.sql:48: ERROR: invalid input syntax for type integer: "one thousand"
LINE 1: INSERT INTO order_items VALUES (1002, 'ESPRESSO', 'one thous...
^
psql:/tmp/orders_relational.sql:49: ERROR: insert or update on table "order_items" violates foreign key constraint "order_items_order_id_fkey"
DETAIL: Key (order_id)=(9999) is not present in table "orders".В таблицах отчёт требует JOIN, зато email меняется в одной строке, а неверные данные не попадают в базу: тип колонки и внешний ключ отклоняют их сразу, при записи.
Таблица выбора
Заказы, оплаты, складские остатки
- Что выбрать
- PostgreSQL или MySQL
- Почему
- Связи, ограничения и транзакции через несколько таблиц
Каталог, где у товаров разные характеристики
- Что выбрать
- PostgreSQL с
jsonbдля характеристик или MongoDB - Почему
- Общие поля — колонками, изменчивые — документом
Контент, анкеты, настройки: объект читают целиком
- Что выбрать
- MongoDB или
jsonb - Почему
- Данные читают и меняют вместе, связей мало
Кэш, сессии, счётчики, лимиты запросов
- Что выбрать
- Redis рядом с основной базой
- Почему
- Доступ по ключу, TTL, атомарный
INCR
Поток событий или телеметрии на многих серверах
- Что выбрать
- База с широкими строками (Cassandra)
- Почему
- Распределение по ключу раздела, таблица под запрос
Рекомендации, социальные связи, маршруты
- Что выбрать
- Графовая база (Neo4j)
- Почему
- Обход связей произвольной глубины
- Какие запросы будут самыми частыми: один объект целиком или срез по многим объектам?
- Есть ли операции, которые должны атомарно изменить несколько сущностей?
- Что продублируется при встраивании и как часто эти данные меняются?
- Кто проверяет структуру данных — база или только код?
- Растут ли вложенные массивы без предела?
Как проверено5 · пройденосверено 11 октября 2026 г.
- запущено, работает
orders.mongo.js (5 операций) и его вывод
docker run --rm mongo:8 (MongoDB 8.3.11, mongosh 2.12.0), mongosh shop --quiet; вывод в статье — дословно из out/orders.mongo.txt, вставлен скриптом build.mjs
- запущено, работает
schema.mongo.js: строковая цена, ошибка $multiply, валидатор $jsonSchema
тот же контейнер: insertOne проходит, aggregate падает «$multiply only supports numeric types, not string», после collMod вставка → «Document failed validation»; errInfo проверен отдельно: propertyName price, reason «type did not match», consideredType string
- запущено, работает
orders_jsonb.sql, jsonb_extra.sql, orders_relational.sql и вывод psql
docker run --rm postgres:17-alpine (17.11), psql -f; результаты совпадают с MongoDB; EXPLAIN с enable_seqscan=off — Bitmap Index Scan on orders_doc_gin; CHECK и FK отклоняют строки; строки NOTICE от DROP TABLE IF EXISTS удалены из показанного вывода (оговорено в подписи)
- запущено, работает
Redis-команды (redis.txt) и вывод
docker run --rm redis:7-alpine (7.4.10), redis-cli --no-raw < redis.txt; в статье команды и реальные ответы сведены в вид интерактивной сессии redis-cli (--no-raw даёт тот же формат ответов, что и интерактивный режим)
- запущено, работает
решения упражнений (exercise.mongo.js, exercise.sql)
после основных скриптов: 7 → 7700, 8 → 2800 в обеих базах
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.