Вложенная схема кэширования по ключам

Published by @squadette on 2016-11-12

Во-первых, я настоятельно рекомендую избавиться от Redis для кэширования и заменить его на Memcached. Redis плохо справляется с ситуациями, когда в одной базе совмещаются персистентные и транзиентные ключи. В первые несколько недель существования Мокума Redis в таком режиме был основным фактором нестабильности сервера. После переезда на Memcached проблемы исчезли как класс.

Во-вторых, важно понимать, что даже если некий запрос выполняется на сервере “мгновенно”, он является существенно дорогим из-за latency обращения к БД (это то, что на профилировщиках обычно очень плохо видно).

Модельный пример будет такой: надо получить список комментариев некоторого поста, видимый некоторому пользователю (с учетом списка банов этого пользователя).

Чтобы получить этот список, нам надо а) слазить в базу за списком банов юзера; б) слазить в базу за списком комментариев поста; в) профильтровать список комментариев через список банов. Мы хотим кэшировать этот процесс. Обычно объект-юзер и объект-пост у нас уже к этому моменту есть, потому что мы использовали их для выяснения существования и авторизации.

Для всех вложенных отношений, которые мы хотим кэшировать, мы создаем соответствующее поле. Например, у нас будут поля <post>.comments_cache_key, <user>.banned_users_cache_key, etc. (лайки, списки подписчиков, списки подписок etc).

Пишем искомую функцию:

function get_post_comments_for_user (user, post) {
	caching_for_user_post(“get_comments”, user, “banned_users”, post, “comments”, function () {
		// эта анонимная функция будет вызвана, если в кэше нет значения
		post_comments = get_post_comments(post);

		banned_users = get_user_banned_users(user);

		return post_comments.filter(function (comment) { return !banned_users[comment.user_id] });
	});
}

function get_post_comments (post) {
	return caching_for_post(“get_comments”, post, “comments”, function () {
			return “select * from comments where post_id = <post.id>”;
		});
}

function get_user_banned_users(user) {
	return caching_for_user(“get_banned_users”, user, “banned_users”, function () {
			return “select * from banned_users where banned_by = <user.id>”;
		});
}

У нас есть три обобщенные функции, caching_for_user_post(), caching_for_user() и caching_for_post(). В принципе их можно обобщить до еще одной супер-универсальной, но от этого сильно теряется читабельность и контроль типов, поэтому копи-пастим. Эти функции стандартно кэшируют — слазили в Memcached, если нет, то вызвали реальную функцию, положили в кэш и вернули. Главный вопрос — это как они конструируют ключ.

Для вызова функции caching_for_post(“get_comments”, post, “comments”, cb) ключ будет выглядеть так: “cfp-get_comments-{post.comments_cache_key}”. Post.comments_cache_key — это, для простоты, поле в таблице posts [на самом деле можно сделать более удачную схему]. Это поле содержит либо кол-во микросекунд с начала эпохи, либо просто увеличивающееся целое число. Все функции, которые изменяют список комментариев поста (добавляют, удаляют, редактируют) внутри транзакции делают “update posts set comments_cache_key = NOW()”. Таким образом, при следующем вызове функции caching_for_post() с третьим параметром “comments” мы будем лазать в БД за комментариями этого поста, потому что ключ уехал вперед.

Аналогично для функции caching_for_user(): при бане/разбане нам надо сделать “update users set banned_users_cache_key = NOW()”.

Теперь возвращаемся к исходной функции get_post_comments_for_user() и увидим, почему эта схема называется “вложенной”. Вызов caching_for_user_post(“get_comments”, user, “banned_users”, post, “comments”, cb) использует следующий ключ, составленный из ключей как юзера, так и поста: “cfpu-get_comments-{user.banned_user_cache_key}-{post.comments_cache_key}”. Таким образом, пересчет кэша для этой функции произойдет как при изменении списка банов юзера, так и при изменении списка комментов поста. Однако, если изменился только список комментов, то мы экономим обращение к базе за списком банов, и наоборот. В типичном случае при изменении поста мы будем лазать в базу только один раз для всех наших пользователей; и наоборот, при генерации ленты для одного юзера мы будем лазить за списком его банов только один раз.

В этой схеме кэш никогда не инвалидируется эксплицитно, просто старые кэшированные значения лежат в памяти memcached и постепенно вымываются оттуда LRU-алгоритмом. Кроме того, не следует использовать в этой схеме TTL, потому что это сильно затруднит отладку — ошибки будут постепенно исчезать к моменту, когда разработчик доберется до них.


2015-2016 Mokum.place