*Этот учебник использует модуль Twilio для Node.js и REST API сообщений Twilio*
*Это третья часть из серии публикаций о создании приложения для голосования в реальном времени через SMS и голосовые вызовы с использованием Node.js. В первой части мы создали приложение на Node.js и собирали входящие голоса через SMS. Во второй части мы реализовали визуализацию голосования в реальном времени с помощью Socket.io. В этой статье мы обсудим, как оптимизировать наше приложение для масштабирования до тысяч голосов в секунду и миллионов голосов в общей сложности.*
В конце второй части этой серии я упомянул, что, хотя у нас есть работающее приложение для голосования по SMS с крутой визуализацией в реальном времени, это не решение, которое может масштабироваться. Причина в том, как я структурировал и извлекал данные. Моя база данных полностью состояла из документов событий, которые выглядели следующим образом:
Вы заметите, что информация о событии (его название и т.д.) и все голоса за это событие содержатся в одном документе. Это означает, что каждый раз, когда мы хотим зарегистрировать новый голос, нам нужно обновлять этот документ. Рабочий процесс выглядит так:
Если голоса поступают быстро, наше приложение в текущем виде быстро выйдет из строя. Причин этому несколько, но одна из них заключается в том, что Node.js не блокируется во время операций ввода-вывода. Мы перегрузим нашу базу данных одновременными чтениями и записями, возникнут конфликты, и голоса будут потеряны. Кроме того, массив телефонных номеров вложен в каждый объект voteoption. Когда голосов миллионы, это довольно много данных для передачи по сети и обработки в нашем приложении. Так что же делать?
Одно из решений — разделить данные о событиях и голосах. Таким образом, когда голоса поступают, мы просто записываем их в базу данных как отдельные документы. Затем мы можем использовать мощь функций представлений map и reduce в CouchDB для запроса данных, необходимых нашему приложению. Кроме этого, мы рассмотрим:
— Использование диапазонных запросов CouchDB для получения разделённой информации о событии и его голосах
— Использование механизма _id в CouchDB для предотвращения многократного голосования одного человека
— Использование функции reduce для быстрого подсчёта голосов
— Реализацию простых кэшей и механизма сброса с использованием массовых операций CouchDB
В SQL-базах данных моделирование отношений «один ко многим» тривиально. Вы определяете внешний ключ в дочерней записи, который указывает на родительскую запись. Одна из ловушек для людей, переходящих с реляционных баз данных, — применять эту стратегию к NoSQL. В CouchDB существует множество способов моделирования таких отношений, и это зависит от того, как работает ваше приложение. В нашем случае мы будем использовать отдельные документы для дочерних записей. Ниже представлена денормализованная версия документа, который я показал в начале этой статьи:
Я отмечу несколько изменений в способе хранения данных:
— Мы добавили атрибут type. Документы могут быть типа event или vote.
— Мы используем type и shortname для создания значения _id для событий. Это упрощает запросы и помогает в отладке.
— Мы используем type, event_id и phonenumber для создания значения _id для голосов. Это гарантирует, что данный номер телефона может проголосовать только один раз за событие.
— Каждый документ голосования содержит ссылку на родительский документ события в event_id.
— Я удалил счётчик голосов из объекта voteoptions. Мы можем использовать CouchDB для генерации этой информации по требованию.
Теперь, когда мы денормализовали наши данные, нужно подумать о том, как мы будем вставлять документы голосов по мере их поступления.
В первой части я кратко упомянул некоторые функции map, которые мы определили для поиска событий. Давайте посмотрим на старую функцию map для представления event/all:
Что здесь происходит? CouchDB пропускает каждый документ в нашей базе данных через эту функцию. В данном случае, если у документа есть атрибут «name», вызывается emit. Думайте о emit как о return, который возвращает пару ключ/значение. После того как функция map выполнена для каждого документа, у нас есть список пар ключ/значение. В данном случае ключ — это имя, а значение — сам документ [1].
Ключ важен. Вы можете вызвать представление без параметров и получить полный список:
Я сделаю паузу здесь, чтобы медленно похлопать в ладоши за RESTful API дизайн CouchDB. Здорово, что я могу получать доступ к своим данным и тестировать представления из командной строки. CouchDB также обладает мощными возможностями запросов. Вы можете передать параметр key в CouchDB, чтобы ограничить ответ этим ключом:
Вы также можете выполнять запросы по диапазону ключей, указав начальный и конечный ключи:
Это вернёт все строки представления, начиная с foo и заканчивая bar [2]. Ваши строки представления предварительно отсортированы по ключу, поэтому диапазонные запросы в этот отсортированный список очень мощные. Теперь, когда мы решили денормализовать наши данные, нам нужно изменить наши функции map для работы как с документами событий, так и с документами голосов. Вот как выглядит новая функция map в представлении event/all:
Хитрость в том, что ключ [«A»] сортируется перед ключом [«A», «1»], что чрезвычайно важно. Далее мы используем event_id в голосе, чтобы наша функция map представления выглядела так:
Затем, в одном диапазонном запросе, вы получаете информацию о событии и все связанные данные голосов:
Две вещи стоит отметить здесь. Опция -g говорит curl не выполнять подстановку, поэтому вам не нужно экранировать скобки и т.д. в URL. Удобно. Во-вторых, пустой объект {} сортируется последним в правилах сортировки представлений, так что это довольно распространённый трюк. Это позволит вам получить доступ ко всем голосам, связанным с событием:
В приведённом выше примере у нас почти миллион голосов за событие A. Если мы просто пытаемся создать страницу просмотра события, всё, что нам действительно нужно, — это информация о событии и количество голосов за каждый вариант голосования. Было бы здорово, если бы CouchDB могла сделать это за нас?
MapReduce, возможно, один из самых известных алгоритмов в компьютерных науках. Это алгоритм для обработки параллелизуемых задач на огромных наборах данных распределённым образом на нескольких компьютерах, который стал наиболее известен благодаря применению Google для индексации веба. Выше мы видели, как функция map создаёт карту пар ключ/значение. Функция reduce обрабатывает все результаты из map и возвращает окончательный вывод.
Мы можем использовать reduce для свёртки таблицы выше и получения общего количества голосов за каждый вариант. Сначала нам нужно создать функцию reduce для нашего представления all. В данном случае мы будем использовать встроенную функцию _count:
Когда же мы вернёмся к написанию кода? Прямо сейчас! Благодаря разделению ответственности в первой части, нам в основном нужно будет изменить наш модуль events.js. Всё остальное в нашем приложении (маршруты, представления и т.д.) не потребует значительных изменений.
**Переход с Cradle на Nano**
Когда я впервые создал это приложение, я использовал Cradle для доступа к CouchDB. Я решил перейти с Cradle на Nano по трём причинам:
— Nano очень минималистичен. Мне нравится быть ближе к «сырому» HTTP-запросу.
— Nano активно поддерживается.
— Нуну поместил динозавра в свой README.
Теперь мы можем заменить множество переменных в config.js в нашем старом приложении на что-то более элегантное:
Инициализация nano в events.js также минималистична:
**Получение количества голосов за каждый вариант**
Когда кто-то заходит на страницу события (http://domain/events/foo), нам нужно получить информацию о событии и количество голосов за каждый вариант голосования. Благодаря нашему map/reduce выше, это простой и быстрый запрос к CouchDB:
**Двухэтапные поиски по номеру телефона**
Один из недостатков денормализации заключается в том, что CouchDB может выполнять запросы к представлениям только на основе ключа (или набора ключей). Поскольку мы не включаем номер телефона события в документ голосования, нет возможности запросить голоса для данного события по номеру телефона. Сначала мы должны запросить событие по номеру телефона, получить его _id, а затем выполнить запрос для получения полного документа события:
Наконец, вы заметите строку, где мы ссылаемся на объект eventsCache. Кэширование, при разумном использовании, наш друг, как мы увидим ниже.
У нас есть два типа документов, которые мы храним в базе данных, поэтому у нас есть две возможности для улучшения производительности с помощью кэширования.
**Кэширование событий**
Документы событий извлекаются и только читаются, но не записываются. Просмотр страницы отображения событий вызовет извлечение, как и входящий голос. Было бы здорово избежать поиска события при вставке голоса, но нам нужен event_id, который мы не можем получить из запроса, полученного от Twilio. Поэтому, когда событие извлекается из базы данных, мы сохраняем его в локальном кэше с ключом, составленным из представления и параметров, переданных в функцию findBy.
Периодически мы хотим сбрасывать этот кэш. Например, мы могли обновить свойство для этого события в нашей базе данных и нам нужно, чтобы это изменение отразилось в нашем приложении. Мы можем настроить задачу, которая будет периодически выполняться для этого:
**Кэширование голосов**
Документы голосов только записываются в базу данных. Чтобы избежать загрузки нашей CouchDB тысячами вставок документов в секунду, CouchDB предоставляет возможность массовой вставки документов. Всё, что нам нужно сделать, — это собирать документы голосов в массив по мере их поступления:
Вы можете задаться вопросом, как долго следует кэшировать голоса перед сбросом в базу данных? Это зависит от вашей терпимости к потере голосов. Если вы можете позволить себе потерять голоса за 30 секунд, сбрасывайте каждые 30 секунд. Если ваша терпимость ниже, сбрасывайте чаще. Конечно, существуют промышленные решения для того, чтобы не терять голоса вообще, но они выходят за рамки этой статьи. Наша функция для сброса голосов выглядит так:
Это много информации, я понимаю, но давайте подведём итог тому, что мы сделали для улучшения этого приложения:
— Денормализовали данные событий и голосов, что позволило нам оптимизировать обработку сохранения
— Использовали диапазонные запросы CouchDB для получения информации о событии и его голосах
— Использовали механизм _id в CouchDB для предотвращения многократного голосования одного человека
— Использовали функцию reduce для быстрого расчёта состояния голосования
— Реализовали простые кэши и сброс голосов с использованием массовых операций CouchDB
И это всё! Вы можете посмотреть работающую версию этого приложения здесь:
На данный момент у нас есть приложение, которое можно вручную настроить для обработки голосования за одно или несколько событий и которое хорошо масштабируется. Последний шаг — превратить этот прототип в продукт. В четвёртой части этой серии мы будем использовать AngularJS для создания аутентифицированного веб-интерфейса, который любой может использовать для настройки собственного опыта голосования.
*[1] Возвращение всего документа в качестве значения для emit — это обычно плохая практика, вместо этого следует возвращать только те значения, которые вам нужны. Вы можете передать параметр include_docs=true в CouchDB, чтобы получить весь документ, если он вам нужен. В нашем случае документы, с которыми мы работаем, небольшие, и нам обычно нужны все их значения.*
*[2] Вы можете задаться вопросом о безопасности и о том, как защитить данные. Краткий ответ заключается в том, что это будет рассмотрено в части 4. Подробный ответ заключается в том, что CouchDB имеет встроенные механизмы безопасности, работающие на уровне базы данных, поэтому каждый пользователь нашего приложения будет иметь свою собственную базу данных.*