С реализацией условного кеширования в Cicero пришлось помучаться. Сначала все казалось дико простым, и я уже обжевывал в голове фразы типа "странно, что такую простую очевидную вещь никто не делает". К концу процесса слова "простая и очевидная" улетучились напрочь. Но вот то, что это нужно делать — это все так же в силе!

Bzr-репозиторий http://softwaremaniacs.org/code/cicero/
Работающий форум http://softwaremaniacs.org/forum/

Conditional get

Я позволю себе небольшой ликбез про само понятие "conditional get", потому что по отсутствию нормальных реализаций у меня сложилось убеждение, что очень многие просто не слышали про это. Кто слышал — можно пропустить главку, как обычно...

В HTTP есть несколько схем кеширования. Одна, как мне кажется, известная больше — это указание браузеру, что ответ серверу можно кешировать какое-то ограниченное время. Это хорошо работает для очень статичных страниц и картинок, и практически совсем не работает для динамических страниц. Взять тот же форум: если человек написал статью, он хочет видеть результат своего воздействия на форум сразу, а не через 15 минут.

Для таких ситуаций работает условное кеширование. Общий его принцип — это присылание сервером вместе со страницей в заголовках какой-нибудь метки, которую браузер запоминает, и при следующем запросе страницы говорит серверу: "дай страницу, только если она не соответствует тому, что у меня уже есть вот с такой меткой". Дальше сервер по этой метке может понять, изменилось ли что-нибудь у него в виде этой страницы. Если изменилось, он присылает всю страницу заново с новой меткой. А если не изменилось, он отплевывает короткий ответ "Not Modified" и не пересылает заново саму страницу.

В качестве метки используются две вещи — время обновления страницы ("Last-Modified") либо произвольный строковый ключик ("ETag"), значение которого может быть в общем-то любым, лишь бы сервер понимал по нему, что страница в браузерном кеше устарела. Они могут использоваться и по отдельности, и вместе. Вот для примера простой диалог браузера и сервера по такой схеме (несущественные заголовки опущены:

  1. Браузер:

    GET /some_page HTTP/1.1
    
  2. Сервер:

    200 OK
    Last-Modified: Fri, 17 Aug 2007 20:46:08 GMT
    
    <куча байтов>
    
  3. Браузер кеширует страницу, запоминая время.

  4. Через некоторое время браузер:

    GET /some_page HTTP/1.1
    If-Modified-Since: Fri, 17 Aug 2007 20:46:08 GMT
    
  5. Сервер, зная, что страница не изменилась с того времени:

    304 Not Modified
    

С ETag'ом все примерно похоже. Сам по себе ETag — это короткая строчка, которая должна быть разной для разных вариантов вида страницы. Самое тупое решение — это md5-хеш от всего ее содержимого. Меняется страница — меняется и хеш. Диалог между браузером и сервером выглядит очень похоже:

  1. Браузер:

    GET /some_page HTTP/1.1
    
  2. Сервер:

    200 OK
    ETag: d58e3582afa99040e27b92b13c8f2280
    
    <куча байтов>
    
  3. Браузер кеширует страницу, запоминая ETag.

  4. Через некоторое время браузер:

    GET /some_page HTTP/1.1
    If-None-Match: d58e3582afa99040e27b92b13c8f2280
    
  5. Сервер, высчитывая заново ETag, и видя, что он совпадает с переданным в If-None-Match:

    304 Not Modified
    

Внимательный читатель спросит, а зачем тогда вообще нужен, например, If-Modified-Since, если ETag работает точно так же, и в него вообще можно передать ту же самую строчку времени? Я не знаю. Могу только поугадывать некоторую теоретическую разницу. Например, теоретически, если браузер пришлет If-Modified-Since позже того Last-Modified, которое получил, то чисто по логике это тоже должно означать "Not Modified". Другое дело, что браузеры такими играми не занимаются, присылают обратно то же самое, что прислали им, и все серверные реализации, которые я видел, проверяют If-Modified-Since по точному совпадению строки, даже не заботясь о том, написано ли там корректное время :-). Еще у If-None-Match есть некая эзотерическая возможность содержать несколько ETag'ов, позволяющая клиенту хранить несколько вариантов одной и той же страницы в кеше, что недостижимо с использованием только времени изменения. Но опять же, на практике этим никто не пользуется.

Django

Как я уже писал, мне не нравится, как в Django реализована поддержка условного кеширования. Она сделана таким образом, что и время последнего изменения, и ETag выясняются после отрабатывания view-функции и полной генерации ответа. А на мой взгляд самое вкусное в условном кешировании не то, что мы разрешаем браузеру не качать данные, а то что мы можем избавить сервер от генерации ответа вообще, если можем быстро и дешево достать время последнего изменения и/или ETag. В случае того же форума время обновления, например, конкретного топика, может высчитываться как время написания его последней статьи, которое из базы получается конечно быстрее, чем содержимое всего топика целиком.

Вот это главное, что мне и хотелось решить в этом смысле в форуме. Результатом стал довольно могучий декоратор для view, принимающий в параметрах две функции, которые считают Last-Modified и ETag. То есть примерно так:

@condition(last_modified=get_last_article_time)
def topic(request, slug, topic_id):
  # ...

Функцию "get_last_article_time" нужно, разумеется, написать пользователю.

Код самого декоратора приводить тут не буду, его можно посмотреть (и позаимствовать) в файле conditional_get.py в коде приложения. Но вообще, я очень хочу предложить его джанговцам для включения в фреймворк. Он получился довольно аккуратным, как мне хочется думать. Например, он заботится о том, чтобы даже быстрые функции вычисления времен и ETag'ов вызывать только один раз, а также поддерживает упомянутую выше возможность передачи в If-None-Match нескольких ETag'ов.

Время последнего изменения

А вот функция last_modified меня покусала, да :-). Сначала я и декоратор, и эту функцию сделал очень простыми и наивными. В частности, декоратор работал только с временами, без ETag'ов, а функция просто брала время последнего добавления статей в показываемой выборке:

def latest_change(request, slug, topic_id):
  return Article.objects.filter(
    topic__forum__slug=slug, 
    topic__id=topic_id).order_by('-created')[0].created

@if_modified_since(latest_change)
def topic(request, slug, topic_id):
  # ...

Она ломалась самыми разнообразными способами. Самое очевидное — это то, что она думает, что изменения страниц форумов зависят только от времени написания статей, что конечно не так. Тут же боком вылезло то, что в процессе чтения статей не меняется вид прочитанности топиков, следующим вылезло то, что удаление статей тоже никак не влияло на протухание кеша и до жесткого рефреша в браузере удаленные статьи продолжали показываться. Ну и наконец в чистом форуме без статей этот запрос просто очевидно падал :-)

Учет удаления статей решился, впрочем, быстро. Я сказал себе спасибо, что реализовал удаление через пометку статей временем удаления, и оно у меня, соответственно, уже и так есть, поэтому я просто взял максимум от времен создания и удаления. А для отсутствия статей специально в декораторе был обработан случай, когда пользовательская функция возвращает None, который трактуется как "условие не совпало".

Для прочтенности статей я сначала бодро зарулил в профайл пользователя поле с временем последнего прочтения, и в функции, соответственно, брал максимум уже из трех времен: последнего написания, последнего удаления и последнего чтения. Но и это решение не стало живучим... Сломалось оно на моменте залогинивания и отлогинивания пользователя, потому что в эти моменты сам по себе профайл то пропадает, то появляется. И соответственно, время прочтения статей тоже то пропадает, то появляется в функции с взятием максимума. Снаружи это выглядит как непредсказуемые "залипания" страницы либо в залогиненном, либо в отлогиненном состоянии.

Вот на этом моменте я понял, что одним только If-Modified-Since мне не обойтись. Даже если бы я хотел отслеживать время последнего отлогинивания, то было бы непонятно, откуда его читать, потому что после этого момент уже совершенно непонятно, что это за пользователь был до того :-).

Тогда мне пришлось таки переделать декоратор "if_modified_since" в более общий "condition" и перевести расчет состояния прочитанности с времени изменения на ETag'и. ETag'ом странички в форуме теперь считается md5-хеш от прочитанных статей, если пользователь известен, либо слово "None", если нет. Таким образом ETag всегда меняется и при чтении статей, и при логине, и при логауте. Время изменения статей считается как и раньше и, работая в паре с ETag'ом, дает надежный признак для генерации страниц только в случае изменений их внешнего вида.

Из всего этого я вынес мораль. Кешировать страницу тем сложнее, чем от большего количества состояний зависит ее вид. Хуже всего, если вид страницы меняется сам по себе просто с течением времени. Например представьте, что время статей мне бы захотелось выводить не константой типа "14:23", а в виде "только что", "минуту назад", "вчера". Тогда ее кешировать вообще практически было бы нельзя.

UnVary

Пока не забыл, упомяну одну деталь, связанную с Internet Explorer'ом. У него есть баг, из-за которого он полностью отключает механизм условного кеширования, если в заголовках ответа присутствует редкий HTTP'шный заголовок Vary. А Джанго его по стечению обстоятельств как раз пишет. Поэтому, чтобы это все работало в IE, я написал когда-то давно маленькую middleware, которая снимает заголовок Vary. В коде Cicero она не лежит, привожу здесь:

class UnVaryMiddleware:
  def process_response(self, request, response):
    if response.has_header('Vary') and response.has_header('Last-Modified'):
      del response['Vary']
    return response

Немножко пижонства

То, что описано к этому моменту, в общем-то уже хорошо. Оно работает так, что человек, ходя по форум в режиме "топик1 - форум - топик2 - форум - топик3" не нагружает сервер генерацией форумных страниц. Но я изначально хотел попробовать сделать так, чтобы он при этом вообще не трогал базу данных!

Тут я должен оговориться, что я наверное сделал это зря. Без реальных тестов я сейчас даже не знаю, насколько часто будет работать conditional get как таковой, не говоря уж о том, насколько сильно это снизит нагрузку на сервер. Откровенно говоря, он сейчас вообще никогда не нагружен :-). Поэтому все, что я тут сейчас буду писать, стоит воспринимать исключительно как своеобразный инженерный перфекционистский спорт :-).

Итак... Нынче хорошим тоном в веб-приложениях является использование кеша в памяти. В частности, memcached, который рекомендует каждый девелопер, его брат и его бабушка. В Джанго он поддержан в качестве одного из backend'ов, и для работы с ним можно пользоваться обобщенным джанговским API.

Этот самый кеш я собираюсь заиспользовать для того, чтобы сохранять в него результаты вычисления Last-Modified и ETag. И таким образом при повторных запросах с If-Modified-Since и If-None-Match заменить пусть относительно быстрые, но все таки запросы в БД совершенно молниеносными запросами в память. Кроме скорости, еще и количество соединений в БД будет экономиться.

Реализуется такая штука отдельным кеширующим декоратором, который я оборачиваю вокруг функций latest_change и user_etag:

def cached(key_func):
  '''
  Кеширующий декоратор.
  '''
  def decorator(func):
    def wrapper(*args, **kwargs):
      key = str(key_func(*args, **kwargs))
      value = cache.get(key)
      if not value:
        value = func(*args, **kwargs)
        cache.set(key, value)
      return value
    return wrapper
  return decorator

@cached(lambda request, slug=None, topic_id=None, *args, **kwargs: 'alc-%s-%s' % (slug, topic_id))
def latest_change(request, slug=None, topic_id=None, *args, **kwargs):
  # запрос времени последнего изменения статей

@cached(lambda request, *args, **kwargs: 'ulc-%s' % request.COOKIES.get(settings.SESSION_COOKIE_NAME, None))
def user_etag(request, *args, **kwargs):
  # Запрос поьзовательского etag'а.

Некоторый интерес может представлять то, что декоратор принимает в качестве параметра функцию вычисления ключа для кеша, потому что декоратор его автоматически не изобретет. Кстати, ключом кеша для user_etag работает джанговская кука, хранящая ID сессии, а не, скажем, ID пользователя, потому что кука доступна из запроса сразу, а за любой другой информацией пришлось бы лезть в базу, чтобы прочитать данные сессии.

Но закешировать функции ответ мало. Потому что кеш в памяти — штука тупая, как закешировала, так и будет отдавать, пока не протухнет. Меня это не устраивает ровно по тем же причинам, по которым я начал писать conditional get: изменения должны быть видны сразу. Поэтому к этому кешированию мне пришлось написать еще две функции, которые точечно стирают закешированные результаты (одна для времен статей, другая — для ETag'ов пользователей). И эти функции приходится вручную звать везде из view, где эти данные меняются.

Пока это самое неприятное на мой взгляд в этой системе, потому что по сути дополнительная функциональность требует вникания в логику работы того, что она дополняет. Ну да ладно, подумаю еще. В конце концов, по словам Фила Карлтона, "There are only two hard things in Computer Science: cache invalidation and naming things".


Собственно вот. Теперь мне хочется какое-то время понаблюдать за логами форума и увидеть, насколько часто и хорошо этот кеш работает.

Комментарии: 12

  1. Виктор

    Вопрос: про какую версию IE идёт речь? (UnVary).

  2. Иван Сагалаев

    6-я. 7-ю не смотрел.

  3. Sergej 'ZaZa' Kurakin

    Я тоже регулярно задаюсь вопросом "странно, что такую простую очевидную вещь никто не делает".
    Причём этому внимание не уделают дажы "туповатые" CMS, у которых почти 80% можно отдавать статикой с использованием кеширования на стороне сервера.

    Из личного опыта общения: если копать глубже, не все WEB-программисты изучают стандарты HTTP и обращают внимание на возможности протокола HTTP.

  4. Сло

    мне тут подумалось, что форматирование вида "только что"/"минуту назад" можно делать javascript'ом, а из сервера отдавать размеченный специальными span'ами текст, например.

    с этим еще можно сделать что нибудь вкусное вроде динамического пересчета, когда через минуту "только что" меняется на "минуту назад", без перезагрузки страницы =)

  5. Давид Мзареулян

    Если не ошибаюсь, в заголовке If-None-Match етаги полагается заключать в кавычки. И там ещё есть всякие тонкости со строгими и нестрогими етагами ("..." и W/"..."): http://xpoint.ru/know-how/VebAlgoritmyi/ConditionalGet?comments

    И эти функции приходится вручную звать везде из view, где эти данные меняются.

    Корявенько, конечно. А разве нет в джанге какой-нибудь сигнальной системы, чтобы не дёргать каждый раз всё руками, а просто навесить перехватчик на нужное событие? У меня было ощущение, что я про что-то такое читал...

  6. Иван Сагалаев

    Если не ошибаюсь, в заголовке If-None-Match етаги полагается заключать в кавычки. И там ещё есть всякие тонкости со строгими и нестрогими етагами (”…” и W/”…”):

    Про нестрогость, признаюсь, не дочитал, очень дурацким языком спецификация написана :-). Кроме того непонятно, как их обрабатывать. И про кавычки, да, наверное поправить надо.

    А разве нет в джанге какой-нибудь сигнальной системы, чтобы не дёргать каждый раз всё руками, а просто навесить перехватчик на нужное событие?

    Система есть конечно. Сигналов только нет нужных. Откуда Джанго знает, что мне понадобится сигнал в середине логики одной из моих же view? Другими словами, сигналами или нет — это вторичный вопрос. Первичный в том, что я не знаю, как не привязывать инвалидацию кеша к специфике приложения.

  7. koct9i

    Внимательный читатель спросит, а зачем тогда вообще нужен, например, If-Modified-Since, если ETag работает точно так же, и в него вообще можно передать ту же самую строчку времени?

    всё просто — If-Modified-Since это из HTTP/1.0, а ETag появился в 1.1

    Време'нные метки это плохо, квантование времени и предположение наличия глобальных часов порождает кучу проблем и потенциальных ошибок, во всех распределённых системах пытаются от них избавится.
    Видимо авторы HTTP тоже осознали это =)

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

    В реальности игра ИМХО не стоит свеч. Генерация страницы без пересылки не так дорого и обходится. В случае если генерация сложная то стоит кэшировать сложно-вычислимые данные или элементы страницы.

  8. Макс Лапшин

    А что такое @ перед названием метода?

  9. Иван Сагалаев

    Мнэ-э... Это подколка на мой аналогичный вопрос на РИТе? :-)

    Если все же нет, то это декоратор — функция, принимающая другую функцию, обвешивающая ее каким-нибудь обслуживающим кодом. У Максима Деркачева есть подробнее.

  10. bw

    Есть еще такой прикол. Вроде о нем не говорили, а то я давно этот топик читал, забыл :-).

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

    Вообщем задача это жутко сложная и при её решении можно легко наломать дров.

    ..bw

  11. [...] Я как-то расписывал это подробно: http://softwaremaniacs.org/blog/2007/08/18/conditional-get-bites/ [...]

  12. Serg

    Можно кешировать уже собранную статику. Если страница не изменилась — отдавать данные из кеша. Иначе, пересобирать ее снова.

Добавить комментарий