Система рейтингов в высоконагруженном проекте

в 22:32, , рубрики: Lua, nginx module, nosql, tarantool, архитектура системы, высокая производительность, высоконагруженные проекты

Рассказ будет про один контентный проект, в котором мне пришлось переделать архитектуру. Ранее была реализована классическая Лампа-схема (Linux-Apache-MySQL-PHP). Но кол-во посетителей прибавлялось и прибавлялось, уже стало подходить к 1М хитов и сервер БД переставал справляться. Первым делом, я предложил докупить еще один серак, но в данном сегменте конвертация в партнерских программах довольно низкая, так что, руководство проекта немного пожмотилось.

Если, интересно, как мне пришлось изменить архитектуру и при этом еще прикрутить систему ротации и рейтингов, то добро пожаловать под кат.

Особенность данного проекта в том, что он раздает видео контент, который находится на сайтах-донорах, типа твоей трубы (YouTube). Сайт должен отображать только ВВ-коды (определенный HTML). Поэтому, не было необходимости постоянно генерить HTML на лету, а делалось это через определенное время, например, раз в сутки, правда потом заменили на ротацию через 1000 показов. Apache заменили на nginx, а сам nginx отдавал просто сгенерированный статический контент HTML.

Каждый раз, придя на сайт, посетитель должен увидеть что-то новенькое. А новенькое, как часто бывает – это хорошо забытое старенькое. Вобщем, нужна была ротация видео превью (о них чуть позже). Существует несколько алгоритмов ротации. Вы даже не представляете изощренность ума маркетологов. Поэтому, расскажу только про один, наиболее простой.

В первые 10 слотов, вставляются только новые превьюшки. Далее, выбираются 90 превьюшек данной категории с максимальным CTR. Кто не знаком с этим термином, это показатель кликабельности, от англ. click-through rate: отношение числа кликов на картинку к числу её показов.

Видео может быть потенциально популярным, а вот превьюшка не презентабельной. Это с большой вероятность может быть, так как вместо студента, который сидит и выбирает самые сочные момменты видео, сидит робот и генерит превьюшки случайно выбранного кадра. Поэтому рейтинг, вполне интересного видео может уйти в “даун”. Чтобы, разнообразить сайт, да и выровнять эффект случайного кадра, используется локальный рейтинг: генерится три превью от одного видео, которые тоже ротируются. В ходе естественного отбора, остаются наиболее привлекательные картинки. Есть еще система голосований: пальчик вверх/пальчик вниз, но её тех-реализация один в один похожа на систему ротации.

Но, мы здесь собрались не SEO-сказки слушать, а поделиться тех деталями. В общем, вся Лампа технология была заменена на сайто-генератор. Nginx работал на отдачу статики. Остаётся только реализовать подсчет CTR.

Так как общее кол-во видео на сайте составляло в районе 100К, то вполне можно выбрать персистентное in-memory хранилище. Какие у нас есть альтернативы: Redis, Aerospike,Tarntool.

Из-за хороших функциональных возможностей и дружественной русско-говорящей поддержки ребят из MailRu выбор пал на Tarantool. MySQL у нас ни куда не делся, в нем продолжают храниться BB-коды видео, списки категорий и наименований, описание контента и прочая информация, которая необходима для сайто-генерации. Но, так как БД практически не использовалась, то ему отвели минимум памяти.

Теперь более подробно про Tarantool (далее по тексту Т*). О нем много было написано в разных статьях Я постараюсь рассказать, как это применимо на практике, опуская настройку и инсталляцию.

Немного скучной теории, чтобы понять что к чему: Все данные в Т* хранятся в пространствах: space. Это аналог таблицы в SQL или коллекции в MongoDb. Как таблица состоит из строк, коллекция из документов, так пространство включает в себя множества кортежей (аналог строки в MySQL).

Кортеж состои из элементов или полей. Мне элементы кортежа удобно называть полями и я буду придерживаться этой терминологии, что не идет в разрез с документацией tarantool.org/doc/book/box/index.html. В отличие от строк таблицы, поля в кортеже не имеют названий, а имеют только порядковый номер. Хотя, как вы увидите в последствии, это не принципиально.

Каждый кортеж должен иметь первичный ключ. Первичный индекс может иметь один из следующих типов: TREE, HASH, BITSET или RTREE. Так же, на пространство можно наложить вторичный индекс, что позволяет делать такие уникальные выборки, которые не возможно сделать в редисе.

image

На рис 1 изображена аналогия MySQL и T*.

Для хранения рейтингов создается пространство stats. Для этого зайдем в консоль и выполним команды:

	box.cfg{}                           – загружает дефолтную конфигурацию
	box.schema.space.create("stats")    – создает новое пространство

Проверим, как создалось наше пространство:

tarantool> box.space 
--- 
- stats: 
    temporary: false 
    engine: memtx 
... 

И присвоим его переменной stats

tarantool> stats = box.space 

Если бы мы составляли схему для БД или MongoDb, то выбрали бы следующую схему:

1	key   	               - первичный ключ, совпадает с id видео
2	clicks_1             – кол-во кликов для первой картинки 
3	clicks_2             –              – || –           второй картинки 
4	clicks_3             –              – || –           третьей картинки 
5	clicks_sum_1 – общее кол-во кликов для первой картинки 
6	clicks_sum_2 –               – || –                     второй картинки             
7	clicks_sum_3 –               – || –                     третьей картинки 
8	show_1               все тоже самое для показов
            …
13	show_sum_3   
14	ctr_1			ctr для первой картинки за последний промежуток
15	ctr_2
16	ctr_3
17	ctr_sum_1		ctr для первой картинки  за весь период
18	ctr_sum_2
19	ctr_sum_3
20	ctr			ctr по всем картинкам за последний промежуток
21	ctr_sum			ctr по всем картинкам за весь период

Первая колонка — это номер поля, определим константами имена полей:

	-- первое поле это первичный ключ
	clicks_1 = 2 
	clicks_2 = 3
	. . .
 	ctr_sum = 22

Создадим в нашем пространстве первичный ключ, выбираем тип HASH:

        stats:create_index('primary', {type = 'hash', parts = {1, 'NUM'}})

Проверим, что создали:

tarantool> stats.index 
--- 
- 0: &0 
    unique: true 
    parts: 
    - type: NUM 
      fieldno: 1 
    id: 0 
    space_id: 513 
    name: primary 
    type: HASH 
  primary: *0 
... 

Очень хорошо, если получилось, а теперь создадим функцию, которая будет инкрементировать поле clicks_1, и для отладки вставим несколько записей:

     stats:insert{1,0,0,0,0,0,0}
     stats:insert{2,0,0,0,0,0,0}
     stats:insert{3,0,0,0,0,0,0}

Сперва проверим, что понавставляли:

tarantool> stats:select{2} 
--- 
- - [2, 0, 0, 0, 0, 0, 0] 
…

Замечательно, у нас все работает! Теперь напишем код инкрементации поля:

tarantool> stats:update(2,{{ '+',2,1 }}) 
tarantool> stats:select{2} 
- [2, 1, 0, 0, 0, 0, 0] 
tarantool> stats:update(2,{{ '+',2,1 }}) 
- [2, 2, 0, 0, 0, 0, 0] 

Команда update имеет следующие параметры:
primary key — номер ключа, по которой производится обновление
вторым параметром идет список действий, каждый элемент которого представляет триплет (список из трех элементов):
— тип действия, в данном случае сложение
— номер поля, над которы проводятся изменения
— число

Подробнее о команде update в документации: tarantool.org/doc/book/box/box_space.html#lua-function.space_object.update

Мы видим, что с каждым выполнением stats:update данные для key=2 второго поля увеличиваются на 1. Запишем в более читабельном виде. Ранее мы должны были задать:

tarantool> clicks_1 = 2 

Выполним:

tarantool> stats:update(2,{{ '+',clicks_1,1 }}) 
- [2, 4, 0, 0, 0, 0, 0] 

Теперь обернем это в функцию:

function click_inc(key) stats:update(key,{{ '+',clicks_1,1 }}) end 

И проверим:

tarantool> click_inc(2) 
tarantool> stats:select{2} 
--- 
- - [2, 5, 0, 0, 0, 0, 0] 
... 
tarantool> click_inc(2) 
tarantool> stats:select{2} 
--- 
- - [2, 6, 0, 0, 0, 0, 0] 
…

Добавим в нашу функцию номер картинки (номер начинается 0 – первая картинка):

function click_inc(key, img_num) stats:update(key,{{ '+',clicks_1 + img _num,  1}}) end

После проверки, приведем функцию в более лучшый вид в отдельнойм файле: click.lua

function click_inc(key, img_num) 
  if img_num >3 then 
    return  false
  end 
   box.space.stats:update(key, {{'+',clicks_1 + img_num,1}})
  return true
end 
 

Как видим, логика исполнения функции довольно проста: первый агрумент – id видео, следующий номер его превью. Теперь рассмотрим, как все это может быть применимо. Для WEB проекта, эту функцию можно вызвать тремя c половиной способами:
— используя пользовательское АПИ: из скриптов PHP/Python/Perl/Java и т.д.
— через tarantool-http, на который будут проксироваться запросы через nginx
или собственный lua-скрипт, используя http.lib или иной web сервер (например xavante)
— непосредственно из nginx, используя nginx_upstreem модуль.

Если есть интерес, могу подробнее рассказать про второй способ, но в данном случаи нами был выбран третий вариант. В статье и так много буковок, так что про установку и настройку модуля можно прочитать в статье Строим сервисы на базе nginx & Tarantool от авторов Т*.

Итак, наш click.lua будет следующий:

#!/usr/bin/tarantool 
 
box.cfg{ 
        log_level = 5; 
        listen = 10001; 
} 
 
click_1 = 2;
 
function click_inc(key, img_num) 
  if img_num >3 then 
    return 0 
  end 
  box.space.stats:update(key, {{'+',click_1 + img_num,1}})    
  return 1 
end 
 
 

Проверим его:

        curl http://127.0.0.1:8081/echo --data '{"method":"click_inc","params":[2,1], "id":0}'
        {"id":0,"result":[[1]]}

Для проверки подконектимся к запущенному экземпляру Т*:

tarantool> console=require("console") 
tarantool> console.connect("127.0.0.1:10001") 
tarantool: connected to 127.0.0.1:10001 
- true 
127.0.0.1:10001> stats = box.space.stats 
127.0.0.1:10001> stats:select{2} 
- - [2, 7, 0, 0] 
... 

Так же мы можем инкрементировать счетчик второй картинки:

 curl http://127.0.0.1:8081/echo --data '{"method":"click_inc","params":[2,2], "id":1}'
{"id":0,"result":[[1]]}
 

Проверим результат:

127.0.0.1:10001> stats:select{2} 
- - [2, 7, 1, 0] 
... 

Мы рассмотрели как просто сделать систему подсчета кликов. Теперь перейдем к системе показов.

Каждая страница множества превьюшек, условно назовем “категории”, по идеи должна вызвать сотню (будем считать что за одна страница категории содержит сто превьюшек из этой категории) раз процедуру инкрементации показов: show_inc. Но, как мы понимаем – это не оптимально. Есть следующий вариант: В теле HTML странице генерится переменная.

<script>
   show_pictupies=”1,2,3,4,5” /* тут перечислены все id показываемых картинок*/
</script>
 
 
 

и далее по AJAX передавать весь этот список. Но тут, кроме id картинки надо передать и её вариант показа, поэтому список может принять сл вид: “1-1, 2-1, 3-1, 4-2”, где циферка после знака минус показывает вариант показа.

Такого аналога функции, как explode в lua, к сожалению, не существует, поэтому погуглив использовали этот код

function split(inputstr, sep)
        if sep == nil then
                sep = "%s"
        end
        local t={} ; i=1
        for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
                t[i] = str
                i = i + 1
        end
        return t
end

Далее проходимя циклом по таблице. Для осуществления цикла, реализуем функцию-итератор:

function values(t) 
  local i = 0 
  return function() i = i + 1; return t[i] end 
end 
 
for it in values(tt) do  show_inc(it, 2  )  end
 

Как вы уже догодались, show_inc очень похожа на click_inc с тем немногим исключением, что переменную click_1 заменяем на show_1. Поэтому, можно создать более уневерсальную функцию, stat_inc(key, field, img_number ).

function stat_inc(key, field, img_num) 
 
  if img_num >3 then 
 
    return 0 
  end 
 
     box.space.stats:update(key, {{'+',field + img_num,1}})    
  return 1 
end 
 
 

Так как, мы осуществляем подсчет двух типов ctr: первый с момента последней генерации и общий, то создадим процедуру click, которую будем вызывать через nginx:

function click( key, img_num) 
  stat_inc(key,  clicks_1, img_num )
  stat_inc(key, clicks_sum_1, img_num)
end
 

а show:

function show( key_list)   
    list = slipt( key_list, ',')
    for it in value(list) 
       do
          pos = string.find(it, “-);
          key = string.sub(it, 0, pos-1);
          img_num = string.sub(it,pos+1)
          stat_inc(key, shows_1, img_num)
          stat_inc(key, shows_sum_1, img_num)
       end
end
 
 

Таким образом, у нас подсчитываются и клики, и показы.

При интересе к этой теме, я могу описать, как расчитывать ctr и как выбирать картинки для формирования HTML.

Автор: akalend

Источник

Поделиться новостью

* - обязательные к заполнению поля