💎 Дневник разработчика CRYSTAL #1 — Симуляция функционирования CRYSTAL v2.0 с помощью синтетических данных, сгенерированных с использованием turboMaker и superMaker
8 октября
обн: 24 октября
Содержание:
1. Введение.
Симуляция нужна для проведения нагрузочного тестирования с миллионами пользователей, постов, лайков, хештегов и т.д.
Цель нагрузочного тестирования — проверка корректности и стабильности работы системы в условиях высокой нагрузки на всех уровнях:
- — Инфраструктура: VPC, Nginx.
- — Серверная часть: backend и база данных.
- — Клиентская часть: frontend.
2. Описание npm пакетов.
Для проведения симуляции, мной были созданы 4 npm пакета с различной функциональностью:
Супербыстрый, многопоточный генератор документов для MongoDB, работающий через CLI. Генерирует миллионы документов с максимальной скоростью, используя все потоки CPU.
Генератор данных, разработанный специально для turboMaker. Генерирует случайные данные: текст, хештеги, слова, email, id, url, array, boolean и т.д.
CLI инструмент для извлечения значений определенного поля из коллекции MongoDB и сохранения их в целевой коллекции.
CLI инструмент для поиска повторяющихся значений в коллекции MongoDB по выбранному полю.
3. Видео с процессом генерации синтетических данных и тестированием функционирования CRYSTAL v2.0.
4. Анализ симуляции и результатов нагрузочного тестирования.
В целом, тестирование с 1,000,000 постов, показало стабильную работу всей системы и достаточно быструю прокрутку постов в infinite scroll.
Но как и ожидалось, загрузка списка хештегов (Актуальные темы), оказалась слишком медленной из-за упрощенной логики извлечения хештегов, которая была выбрана для ускорения разработки на ранних этапах.
Для получения популярных хештегов, проводится обход всех постов через агрегацию ($unwind, $group), что существенно замедляет процесс загрузки:
hashtag.controller.js
import { PostModel } from "../../modules/post/index.js";
import {
handleServerError
} from "../../shared/helpers/index.js";
export const getHashtags = async (req, res) => {
const { limit } = req.query;
const max = parseInt(limit) || 6;
let result = await PostModel.aggregate([
{
$unwind: "$hashtags"
},
{
$group: {
_id: "$hashtags",
"hashtag": {
$first: "$hashtags"
},
"numberPosts": {
$sum: 1
}
}
},
{
$sort: {
"numberPosts": -1,
"hashtag": 1
}
},
{
$project: {
"_id": false
}
}
]).collation({ locale: 'en', strength: 2 }).limit(max).exec();
try {
return res.status(200).json(result);
} catch (error) {
handleServerError(res, error);
}
};
post.model.js
import mongoose from 'mongoose';
const PostSchema = new mongoose.Schema(
{
title: {
type: String,
default: ''
},
text: {
type: String,
default: ''
},
mainImageUri: String,
hashtags: {
type: Array,
default: [],
},
liked: {
type: Array,
default: [],
},
views: {
type: Number,
default: 0,
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
},
{
timestamps: true,
},
);
PostSchema.index({ createdAt: -1 });
export const PostModel = mongoose.model("Post", PostSchema);
5. Новая система хештегов.
Для ускорения загрузки хештегов, была создана новая система, и теперь хештеги будут храниться в отдельной коллекции — hashtags:
hashtag.model.js
import mongoose from 'mongoose';
const HashtagSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true,
lowercase: true,
},
postId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Post',
required: true,
},
postCreatedAt: {
type: Date,
required: true,
},
},
{
timestamps: true,
}
);
HashtagSchema.index({ name: 1, postCreatedAt: -1, postId: 1 }, { unique: true, collation: { locale: 'en', strength: 2 } });
HashtagSchema.index({ name: 1 });
HashtagSchema.index({ postId: 1 });
export const HashtagModel = mongoose.model('Hashtag', HashtagSchema);
Индексы:
1. index({ name: 1, postCreatedAt: -1, postId: 1 }, { unique: true, collation: { locale: 'en', strength: 2 } });
— compound index для быстрого поиска и курсорной пагинации.
2. index({ name: 1 });
— для подсчета общего количества постов с определенным хештегом (aggregation stage).
3. index({ postId: 1 });
— для быстрого удаления всех хештегов поста.
Вывод хештегов для компонента — 'Актуальные темы':
hashtag.controller.js
import { HashtagModel } from './hashtag.model.js';
import { handleServerError } from '../../shared/helpers/index.js';
export const getHashtags = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 6;
const result = await HashtagModel.aggregate([
// 1. Группировка и подсчет количества постов для каждого хештега
{ $group: { _id: "$name", quantity: { $count: {} } } },
// 2. Сортировка: сначала по количеству (DESC), затем по имени (ASC)
{ $sort: { quantity: -1, _id: 1 } },
// 3. Ограничение общего результата
{ $limit: limit },
// 4. Проецирование результата в нужный формат
{ $project: { name: "$_id", quantity: "$quantity", _id: 0 } }
]);
return res.status(200).json(result);
} catch (error) {
handleServerError(res, error);
}
};
Вывод постов с определенным хештегом, и infinite scroll, происходят с курсорной пагинацией, что существенно увеличивает скорость загрузки постов, по сравнению с offset-пагинацией, особенно при глубокой прокрутке:
post.controller.js
export const getPostsByHashtag = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const hashtag = req.query.tag.toLowerCase();
// 1. Создание запроса и получение отсортированных ID (быстрый find с индексом)
let hashtagQuery = { name: hashtag };
if (req.query.cursor) {
const cursorDate = new Date(req.query.cursor);
if (isNaN(cursorDate.getTime())) {
return res.status(400).json({ message: 'Invalid cursor date' });
}
hashtagQuery.postCreatedAt = { $lt: cursorDate };
}
const hashtagDocs = await HashtagModel.find(hashtagQuery)
.sort({ postCreatedAt: -1 })
.limit(limit)
.select('postId postCreatedAt')
.lean()
.exec();
const postIds = hashtagDocs.map(doc => doc.postId);
if (postIds.length === 0) {
return res.status(200).json({ posts: [], nextCursor: null });
}
// 2. Создание карты соответствия (ID → индекс) для сортировки
const idToIndexMap = new Map();
postIds.forEach((id, index) => idToIndexMap.set(id.toString(), index));
// 3. Выполнение безопасного и быстрого '$in' на PostModel (unordered)
const fetchedPosts = await PostModel.find({ _id: { $in: postIds } })
.populate({
path: 'user',
select: ['name', 'customId', 'bio', 'status', 'creator', 'avatar', 'createdAt', 'updatedAt'],
})
.exec();
// 4. Финальная сортировка (Быстрая O(N log N) на массиве из 'limit' элементов)
const resultPosts = fetchedPosts.sort((a, b) => {
const indexA = idToIndexMap.get(a._id.toString());
const indexB = idToIndexMap.get(b._id.toString());
return indexA - indexB;
});
// 5. Определение следующего курсора
const nextCursor = hashtagDocs[hashtagDocs.length - 1].postCreatedAt.toISOString();
return res.status(200).json({ posts: resultPosts, nextCursor });
} catch (error) {
handleServerError(res, error);
}
};
Для сортировки постов с определённым хештегом (по дате создания), была применена денормализация:
При создании или обновлении поста содержащего хештег, в документ коллекции hashtags, добавляется денормализованное поле — postCreatedAt, в котором хранится дата создания поста. Это позволяет выполнять сортировку и фильтрацию постов по дате, напрямую в коллекции hashtags, без дополнительных обращений к коллекции posts. В getPostsByHashtag, поиск выполняется в коллекции hashtags с помощью быстрого метода find(), эффективность которого обеспечивается compound index — { name: 1, postCreatedAt: -1 }, который позволяет MongoDB, быстро находить нужные записи в требуемом порядке.
post.controller.js
export const createPost = async (req, res) => {
try {
const combiningTitleAndText = (req.body?.title + ' ' + req.body.text).split(/[\s\n\r]/gmi).filter(v => v.startsWith('#'));
const hashtags = takeHashtags(combiningTitleAndText).map(tag => tag.replace(/^#/, '').toLowerCase());
if (hashtags.length > 30) {
return res.status(400).json({ message: 'Maximum 30 hashtags allowed' });
}
if (hashtags.some(tag => tag.length > 70)) {
return res.status(400).json({ message: 'Each hashtag must be 70 characters or less' });
}
const doc = new PostModel({
title: req.body?.title,
text: req.body.text,
mainImageUri: req.body.mainImageUri,
user: req.userId._id,
});
const mainImageUri = req.body.mainImageUri;
const text = req.body.text;
if (!(mainImageUri || (text.length >= 1))) {
return res.status(400).json({ message: 'Post should not be empty' });
}
// 1. Сохранение поста (возвращает ID и createdAt(postCreatedAt))
const post = await doc.save();
// 2. *** Логика хэштегов ***
const postId = post._id;
const postCreatedAt = post.createdAt;
if (hashtags.length > 0) {
const hashtagDocs = hashtags.map((tag) => ({
name: tag.toLowerCase(),
postId,
postCreatedAt, // Денормализованное поле
}));
await HashtagModel.bulkWrite(
hashtagDocs.map((doc) => ({
insertOne: { document: doc },
})),
{ ordered: false }
);
}
// *** Конец логики хэштегов ***
res.status(200).json(post);
} catch (error) {
handleServerError(res, error);
}
};
Для избежания лимита в 16 MB при использовании оператора - $in в getPostsByHashtag, процесс получения постов был разделен на два этапа:
1. Быстрый Index Scan.
Выполняется обращение к коллекции hashtags, в которой выбираются только нужные идентификаторы постов, при помощи .limit(N) (обычно N = 10-20). Таким образом формируется компактный массив postIds фиксированного размера, содержащий только необходимые ссылки на посты.
2. Безопасный запрос с $in.
Массив postIds используется для выборки полных данных из коллекции posts.
Благодаря малому количеству идентификаторов в postIds, запрос выполняется безопасно и полностью исключает риск превышения BSON-лимита в 16 MB.
6. Планы по дальнейшему улучшению структуры базы данных.
В версии CRYSTAL v2.0 планируется вынести лайки в отдельную коллекцию, оптимизированную для масштабируемой обработки реакций. Кроме того, Mongoose будет полностью заменён нативным драйвером MongoDB, что обеспечит значительный прирост производительности CRUD-операций, снизит задержки при массовых запросах и повысит общую эффективность взаимодействия с базой данных.
Поделиться
BTC (Network BTC) - 1C2EWWeEXVhg93hJA9KovpkSd3Rn3BkcYm
Ethereum (Network ERC20) - 0x05037ecbd8bcd15631d780c95c3799861182e6b8





Прокомментировать в