
Привет! Я Павел Кокошников, главный разработчик в контактных политиках Т-Банка. Расскажу про кейс с Cassandra. Базу мы выбрали неслучайно: под наш профиль нагрузки, модель доступа и требования к TTL она подходила хорошо. Уже в работе обнаружилась одна особенность TTL, которую мы упустили на этапе проектирования. Из-за нее в полностью исправной базе появлялись логически битые строки.
В статье разберу, как была устроена модель данных, в чем оказалась проблема, какие варианты решения мы рассматривали и какое выбрали в итоге.
Описание сервиса
Наш сервис контактных политик решает, можно ли отправить клиенту новую коммуникацию. Для этого он поднимает историю событий клиента за последний месяц и прогоняет ее через набор правил.
Данных много: около 50 млн клиентов, до 100 событий на клиента в месяц, у каждого события может быть несколько обновлений статуса. Данные хранятся месяц, после чего удаляются по TTL.
Под нагрузкой это примерно 3к RPS на запись и 10к RPS на чтение. При этом чтение всей истории клиента и применение правил должны укладываться в 100 мс.
Почему выбрали Cassandra
На этапе выбора мы смотрели на Postgres и Cassandra — обе базы были доступны у нас как сервис. Основной вопрос: какая БД лучше переживет нашу нагрузку и дальнейший рост?
Сервис должен был выдерживать высокий поток чтения и записи, а через год мы ожидали минимум двукратный рост. Для Postgres это почти наверняка означало бы ручной шардинг со всей сопутствующей сложностью: маршрутизацией запросов, перераспределением данных и дополнительной логикой на стороне приложения.
В нашем случае Cassandra выглядела более естественным выбором:
основной запрос хорошо укладывался в чтение одной партиции;
горизонтальное масштабирование для нее — штатный сценарий;
TTL хорошо ложился на задачу хранения событий с ограниченным сроком жизни.
Мы выбрали Cassandra как более практичное решение.
Модель данных, операции и схема таблицы
Для каждого события храним:
client_id— идентификатор клиента;id— идентификатор события;created— время создания;payload— набор атрибутов в JSON, примерно 1 KB;status— текущий статус обработки.
По бизнес-требованиям нам нужно было реализовать четыре базовые операции:
сохранить событие;
обновить статус события;
прочитать все события клиента вместе со статусами;
автоматически удалить устаревшие данные по TTL.
В Cassandra схема проектируется от запросов. Самый важный запрос для нас — получить все события конкретного клиента. У него нагрузка около 10к RPS, и он должен читать порядка 100 событий быстро и предсказуемо.
CREATE TABLE event ( client_id text, id uuid, created timestamp, payload text, status text, PRIMARY KEY ((client_id), id) ) WITH default_time_to_live = 2592000;
Причины, почему схема именно такая:
client_id— partition key, потому что основной запрос читает историю одного клиента целиком;партиции получаются небольшими и ограниченными по размеру;
событие и его статус храним в одной таблице, чтобы не делать два чтения на самый горячий запрос.
Немного метрик
Наш кластер развернут в двух дата-центрах, по 9 нод в каждом. Нагрузка постепенно растет и сейчас составляет около 3к RPS на запись и 10к RPS на чтение.
Имя метрики | Чтение истории клиента (основной запрос) | Запись/обновление |
p99 latency | 25 мс | 3 мс |
p50 latency | 5 мс | < 1 мс |
p99 размер ответа | 20 КБ | - |
p99 затронутые SSTable | 4 в p99 | 1 |
Добиться результатов помогли:
включение сжатия на драйвере — без него ответ на основном запросе получался заметно больше и дольше передавался по сети;
переход на LeveledCompactionStrategy — это заметно улучшило чтение. До смены стратегии Cassandra в p99 читала до 9 SSTable на один запрос истории клиента.
Как работают SSTable, compaction и TTL
Cassandra использует LSM-подход. Запись сначала попадает в commit log — журнал на диске. Затем в memtable — память ноды кластера. Когда memtable достигает определенного размера (зависит от настроек), она сбрасывается на диск в виде упорядоченных SSTable.

SSTable неизменяемы: после записи они не редактируются. При вставке значений по существующему ключу обновления попадают в следующие SSTable. Чтобы чтение со временем не превратилось в поиск по множеству файлов, Cassandra выполняет compaction — фоновый процесс, который сливает несколько SSTable в новую, оставляя только актуальные значения и удаляя устаревшие.
Compaction решает две задачи:
ускоряет чтение, уменьшая число SSTable, которые нужно проверять при запросе;
чистит «мусор» — удаляет устаревшие версии данных.

TTL (Time To Live) в Cassandra — время жизни данных, после которого значение считается устаревшим. Истечение срока работает на уровне ячеек, то есть отдельных значений колонок. Cassandra записывает TTL в метаданные ячейки в момент записи.
После истечения TTL значение логически устаревает и больше не участвует в чтении. Физически такие данные могут еще какое-то время оставаться в SSTable и окончательно исчезают позже, во время compaction. То есть TTL влияет на видимость данных при чтении раньше, чем на их фактическое удаление с диска.
Ключевой момент: TTL в Cassandra живет на уровне отдельных ячеек, а не строки целиком. Запомним это — поможет разобраться в проблеме.
Проблема: TTL разошелся между ячейками
В какой-то момент мы заметили рост числа 500-ответов. По логам все выглядело как обычный NullPointerException, но проблема была в том, что в этих местах NPE, по нашей логике, быть не могло. Возникало ощущение, будто мы каким-то образом записываем в Cassandra битые данные, например строку без payload.
Проверка записи ничего не дала: данные писались и обновлялись корректно. Я заметил странность: все битые записи выглядели одинаково — в них отсутствовали поля, добавленные во время первичной вставки. Кроме ключа (client_id, id), оставался только status. Это навело на мысль проверить TTL ячеек. После проверки стало понятно: проблема связана с тем, как Cassandra применяет TTL при обновлениях.
Проблема оказалась не в Cassandra, а в нашей модели записи и в том, как работает TTL.
Мы обновляли только часть колонок строки, а TTL живет на уровне отдельных ячеек. В результате часть полей к моменту чтения успевала устареть, а часть, обновленная позже, оставалась видимой. Именно поэтому мы и получали логически битую строку: status еще есть, а payload уже исчез.
Воспроизводим проблему на пальцах
Чтобы лучше понять, где именно возникает расхождение TTL, воспроизведем ситуацию в Cassandra на нашей модели данных: сначала вставим событие целиком, а затем обновим только одну колонку — status. Для демонстрации уменьшим TTL до 600 секунд:
CREATE TABLE event ( client_id text, id uuid, created timestamp, payload text, status text, PRIMARY KEY ((client_id), id) ) WITH default_time_to_live = 600;
Добавим значение:
INSERT INTO event (client_id, id, created, payload, status) VALUES ('5-AAAAAA', 11111111-1111-1111-1111-111111111111, toTimestamp(now()), 'hello', 'NEW');
Проверим время жизни ячеек. Функция TTL() возвращает оставшееся время жизни ячейки:
SELECT TTL(created) AS ttl_created, TTL(payload) AS ttl_payload, TTL(status) AS ttl_status, created, payload, status FROM event WHERE client_id ='5-AAAAAA' AND id = 11111111-1111-1111-1111-111111111111; ttl_created | ttl_payload | ttl_status | created | payload | status -------------+-------------+------------+---------------------------------+---------+-------- 574 | 574 | 574 | 2026-01-21 09:54:08.324000+0000 | hello | NEW
Счетчик TTL уже начал обратный отсчет — это отражает время, которое ушло на ввод второй команды.
Обновим статус:
UPDATE event SET status = 'SENT' WHERE client_id ='5-AAAAAA' AND id = 11111111-1111-1111-1111-111111111111;
И проверим TTL ячеек еще раз:
SELECT TTL(created) AS ttl_created, TTL(payload) AS ttl_payload, TTL(status) AS ttl_status, status FROM event WHERE client_id ='5-AAAAAA' AND id = 11111111-1111-1111-1111-111111111111; ttl_created | ttl_payload | ttl_status | status -------------+-------------+------------+-------- 540 | 540 | 592 | SENT
TTL ячеек уже разошелся: время жизни в ячейке status оказалось больше, чем в остальных. В какой-то момент значения в ячейках created и payload исчезнут, и мы увидим:
SELECT created, payload, TTL(status) AS ttl_status, status FROM event WHERE client_id ='5-AAAAAA' AND id = 11111111-1111-1111-1111-111111111111; created | payload | ttl_status | status -------------+-------------+------------+-------- null | null | 90 | SENT
В Cassandra TTL применяется к записываемым ячейкам, а не ко всей строке как к единому объекту. Поэтому, если после первоначальной вставки обновлять только часть колонок, их TTL начинает жить своей жизнью. Чтобы заново выровнять TTL для всей логической записи, фактически придется переписать все нужные колонки целиком.
Информация о работе TTL в Cassandra — не тайна. Но при проектировании мы это упустили.
Какие решения рассматривали
Нам нужно было найти компромисс: убрать из чтения строки, у которых уже истекла исходная часть события, но не ухудшить горячий сценарий — чтение всей истории клиента. Поэтому каждое решение мы оценивали по трем критериям: сколько дополнительных запросов оно добавляет, как влияет на запись и compaction.
Чтение перед записью. Самое очевидное решение — читать всю запись и потом перезаписывать всю строку с новым TTL. Минусы:
лишний запрос чтения перед записью — при нашей нагрузке это накладно;
если запись большая, создается лишняя нагрузка на диск: при вставке в SSTable пишется больше, объем compaction в целом растет.
Главная проблема — возросшее потребление ресурсов. Вариант имеет смысл, если нужно продлевать TTL всей записи целиком. В нашем случае такой задачи не было, поэтому пошли смотреть альтернативы.
Отдельная таблица для апдейтов. Вынести обновления в отдельную таблицу и хранить их отдельно от исходного события. Минусы:
нужно два запроса при чтении, чтобы получить все события и их обновления, — на большой нагрузке это существенно;
по сути проблема никуда не уходит и появляются обновления без оригинальных событий создания, их придется отбрасывать при чтении.
Решение рабочее, но для нас был критичен latency на чтении, поэтому отбросили сразу.
Обновление с целевым TTL. Можно попытаться вычислить TTL соседних колонок функцией TTL() и через USING TTL в UPDATE сделать обновление статуса с остаточным значением. Но между моментом, когда мы прочитали TTL, и моментом обновления проходит какое-то время. В результате опять можно получить запись с неравными TTL в разных ячейках. Заметить эту проблему будет гораздо сложнее: расхождение мизерное и воспроизводиться будет крайне редко.
Решение в целом некорректное, но отрицательный результат тоже результат. Может, кому-то поможем сэкономить время при обдумывании своего решения.
Фильтрация по полю created. Один из вариантов, который мы думали оставить. Поле created мы вставляем только при первой вставке, поэтому можно сказать, что ячейка created задает TTL, который нам нужен. При чтении просто фильтруем на клиенте записи, где created == null, — так откидываем битые.
Так был устроен хотфикс. Решение рабочее, но было сомнение, хорошо ли использовать поле created для этих целей. В итоге решили завести отдельное поле, которое отвечает только за то, что строка валидна.
Заведение специального поля. Завели колонку is_alive — признак того, что TTL строки еще не вышел. При вставке is_alive = true записывается только в момент создания записи, вместе с основным TTL. При чтении на клиенте делаем проверку: если поле задано, запись актуальная, оставляем; если нет — отбрасываем.
Разберем на примере:
CREATE TABLE event ( client_id text, id uuid, created timestamp, payload text, status text, is_alive boolean, PRIMARY KEY ((client_id), id) ) WITH default_time_to_live = 604800;
При первоначальной вставке записываем is_alive = true вместе с основными колонками. Колонка получает тот же TTL, что и остальные, и служит маркером того, что исходная запись еще валидна:
INSERT INTO event (client_id, id, created, payload, status, is_alive) VALUES ('5-AAAAAA', 11111111-1111-1111-1111-111111111111, toTimestamp(now()), 'hello', 'NEW', true);
Производим update одной колонки:
UPDATE event SET status = 'SENT' WHERE client_id ='5-AAAAAA' AND id = 11111111-1111-1111-1111-111111111111;
Делаем запрос по истечении основного TTL вставки:
SELECT created, payload, status, is_alive FROM event WHERE client_id ='5-AAAAAA' AND id = 11111111-1111-1111-1111-111111111111; created | payload | status | is_alive -------------+----------+------------+-------- null | null | SENT | null
Понять, что эту запись нельзя использовать, легко: is_alive = null. Значит, первичная вставка уже неактуальна и строку можно не учитывать.
Плюсы и минусы выбранного решения | |
+ одна таблица, один запрос чтения на самую горячую операцию; + update только статуса — меньше объем compaction; + колонка | − лишнее поле, правда, оно − при чтении приходится отфильтровывать устаревшие значения вручную; − часть данных в базе становится «мусором» — оставшиеся значения в ячейке |
Для нашего сценария это оказалось самым практичным компромиссом: сохранили один запрос чтения на самую горячую операцию, избежали увеличения объема записи и получили явный признак валидности строки.
Вывод
Cassandra хорошо подошла для нашего сценария по нагрузке и модели доступа. Но этот кейс показал, что в ее внутреннюю механику нужно вникать заранее.
TTL в Cassandra — не декоративная настройка хранения, а часть модели данных. Если проектировать схему без учета того, как работает TTL, можно получить логически битые строки даже при полностью исправной базе.
