В ходе работы над системой документооборота возникла задача — кэшировать справочники, использующиеся на клиентской стороне. Cистема была спроектирована в виде трехзвенки (БД — сервер приложения — клиентская часть), поэтому простора для фантазии было много.
Исходные условия: несколько десятков справочников, отличающихся по объему от нескольких записей до нескольких десятков тысяч записей в каждом. Каждая запись для большинства справочников хранит в себе полезные данные (обычно строку) и идентификатор записи (целое число).
Шаг первый
Самый простой способ доступа к справочникам на клиенте — кэшировать их перед первым обращением. А после окончания работы с этими справочниками — удалять из памяти. Решение простое, но трафик между клиентом и сервером оказывается слишком большим.
Шаг второй
Напрашивается оптимизация: не получать при обновлении все данные заново, если они не изменялись на сервере. Для этого при загрузке справочника с сервера нужно получать не только сам справочник, но и метку его модификации — число, позволяющее определить “версию” справочника (timestamp).
В момент старта сервера приложения это число для каждого справочника равно единице (ноль зарезервирован, о чем ниже). При каждой модификации справочника на сервере к числу прибавляется единица. То, что timestamp является целым числом, позволяет обойтись очень простой потокозащищенной функцией — InterlockedIncrement.
Когда клиенту нужно получить справочник, он передает на сервер последний известный клиенту timestamp. Если совпадает — сервер передает клиенту 0 и больше ничего. Т.е. на клиенте справочник актуален. Если же метка на клиенте и сервере отличается, то сервер передает клиенту метку + новую версию справочника.
Граблей в этом случае возникает двое. Первые грабли в том, что сервер должен передавать на клиент значение timestamp, которое было до запроса справочника из БД. Иначе может возникнуть ситуация, когда сервер передаст клиенту старые данные и новый timestamp. Вторые грабли с моментом обновления timestamp: сервер должен обновлять timestamp справочника лишь после того, как транзакция будет успешно завершена. Это важно потому, что данные не будут переданы другому клиенту до тех пор, пока транзакция первого не будет закрыта (клиентов несколько — все могут модифицировать и запрашивать данные одновременно).
Шаг третий
Описанный выше способ работал у нас довольно долго, пока не обнаружилась очередная засада — на высоколатентных соединениях (при подключении через интернет, а не по локалке) основное время занимало ожидание ответа от сервера по каждому справочнику, а не на передачу самих справочников. Справочников перед большинством действий приходилось обновлять около десятка, а в большинстве случаев при запросе обновления сервер возвращает, что обновление не требуется. Получалась ситуация — обновление справочника занимает микросекунды, а ожидание ответа от сервера — 10-100 миллисекунд. В результате общее обновление занимает уже около секунды (10 справочников по 100 миллисекунд на каждый).
Решение простейшее. Отправить на сервер запросы сразу обо всех требуемых справочниках одним пакетом. Т.е. массив пар <ID справочника> + <последняя известная клиенту метка справочника>. Сервер присылает ответ в виде массива <ID справочника> + <актуальная метка> + (если требуется) <сам справочник>.
Это позволило пользователям нормально работать дистанционно без использования удаленного рабочего стола.
Шаг четвертый
Количество пользователей росло, данные обновлялись всё чаще. Становилось обидно, что приходится целиком обновлять справочники, состоящие из ста тысяч записей даже в том случае, если они изменились всего на одну запись.
Напрашивается решение. Хранить на сервере приложения лог изменений справочника — ID добавленных и удаленных записей для каждого timestamp. При каждом изменении справочника записывать об изменениях в лог (в логе хранится только ID элемента справочника и тип изменения). А когда клиент присылает свою метку, сервер просматривает лог, начиная с присланной метки и передает клиенту добавленные записи (получая их из БД) и ID удаленных. Трафик между клиентом и сервером, а также нагрузка на БД сокращается в сотни раз.
Главный подводный камень тут то, что запись в лог происходит непосредственно при изменении справочника, то в случае отката транзакции после этого получится, что в лог изменения записаны, а на самом деле не произведены.
Решением стало хранение предварительных логов каждым потоком до тех пор, пока не будет подтверждена транзакция. Только после того, как обработка транзакции завершится, можно записывать предварительный лог в основной, доступный всем потокам.
Будет здорово, если читатели подскажет следующие шаги для оптимизации.
Автор: altush