En

💎 Дневник разработчика CRYSTAL #1 — Симуляция функционирования CRYSTAL v2.0 с помощью синтетических данных, сгенерированных с использованием turboMaker и superMaker


1. Введение.


Симуляция нужна для проведения нагрузочного тестирования с миллионами пользователей, постов, лайков, хештегов и т.д.


Цель нагрузочного тестирования — проверка корректности и стабильности работы системы в условиях высокой нагрузки на всех уровнях:


  • — Инфраструктура: VPC, Nginx.
  • — Серверная часть: backend и база данных.
  • — Клиентская часть: frontend.

2. Описание npm пакетов.


Для проведения симуляции, мной были созданы 4 npm пакета с различной функциональностью:


turboMaker

Супербыстрый, многопоточный генератор документов для MongoDB, работающий через CLI. Генерирует миллионы документов с максимальной скоростью, используя все потоки CPU.


superMaker

Генератор данных, разработанный специально для turboMaker. Генерирует случайные данные: текст, хештеги, слова, email, id, url, array, boolean и т.д.


mongoCollector

CLI инструмент для извлечения значений определенного поля из коллекции MongoDB и сохранения их в целевой коллекции.


mongoChecker

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-операций, снизит задержки при массовых запросах и повысит общую эффективность взаимодействия с базой данных.


CRYSTAL тестируется в

BrowserStack

Поделиться

Копировать

BTC (Network BTC) - 1C2EWWeEXVhg93hJA9KovpkSd3Rn3BkcYm

Ethereum (Network ERC20) - 0x05037ecbd8bcd15631d780c95c3799861182e6b8

Похожие посты

Этот сайт использует файлы cookies. Нажимая кнопку 'Принять' или продолжая пользоваться сайтом, вы соглашаетесь на использование файлов cookies.