16 сентября я переводил сервис прогнозов на общий Redis. У него был свой контейнер, он давно лежал, а на машине не хватало памяти, и поднимать второй инстанс ради одного ключа было глупо. Рядом работал Redis основного сервиса, туда и переехали.
Перед переключением я заглянул, что лежит в чужой базе, и увидел там ключ current_occupancy_cache.
Ровно такой же ключ пишет сервис прогнозов.
Разошёл по разным номерам баз, REDIS_DB=1 вместо нуля, и записал в коммит, что иначе сервисы затирали бы кэш друг другу. Сегодня я полез проверять эту фразу, чтобы написать про неё статью, и обнаружил, что всё было интереснее.
Что я увидел сегодня
Ключи у сервисов разные.
parkings current_occupancy_cache:all parkings-ml current_occupancy_cache
При этом в обоих репозиториях лежит одна и та же константа, объявленная независимо:
# parkings/app/dao/db_operations.py CURRENT_OCCUPANCY_CACHE_KEY = 'current_occupancy_cache' # parkings-ml/app/controller_current_occupancy.py CURRENT_OCCUPANCY_CACHE_KEY = "current_occupancy_cache"
Суффикс у первого добавляется при сборке ключа:
cache_key = f'{CURRENT_OCCUPANCY_CACHE_KEY}:{city or "all"}'
Посмотрел, когда появилась эта строка. git log -S отвечает однозначно: 18 сентября, в коммите про мультигородность. Через два дня после того, как я разводил базы.
То есть коллизия, от которой я защищался, перестала быть возможной сама по себе. Её убрала фича в другом репозитории, написанная мной же, по другому поводу, и ни в одном из её коммитов сервис прогнозов не упоминается.
Чем эта коллизия была 16 сентября
Вот тут начинается то, ради чего я это пишу.
Я вытащил обе версии кода на тот день и сравнил, что именно каждый сервис клал в этот ключ.
parkings:
results.append({ 'parking_id': occ.parking_id, 'occupancy': occupancy, 'free_spaces': free_spaces, 'free_handicapped_spaces': free_handicapped_spaces, 'total_spaces': total_spaces, 'common_spaces': common_spaces, 'handicapped_spaces': handicapped_spaces, 'coordinates': {'latitude': latitude, 'longitude': longitude}, })
parkings-ml:
results.append({ "parking_id": occ.parking_id, "occupancy": occupancy, "free_spaces": free_spaces, "free_handicapped_spaces": free_handicapped_spaces, "total_spaces": total_spaces, "common_spaces": common_spaces, "handicapped_spaces": handicapped_spaces, "coordinates": { "latitude": latitude, "longitude": longitude } })
Поле в поле. Разные репозитории, разные кавычки, одинаковая структура. TTL у обоих 300 секунд. Сериализация у обоих одна и та же, причём одинаково кривая: оба делают json.dumps снаружи, а внутри кэш делает json.dumps ещё раз.
Значит, если бы я оставил нулевую базу, сервисы бы честно перетирали друг другу ключ каждые пять минут, и ничего бы не произошло. Ни ошибки, ни расхождения в ответах, ни строчки в логе. Два процесса писали бы в одно место одно и то же.
Коллизия была бы не просто тихой. Она была бы корректной.
Почему это хуже, чем поломка
Через два дня ключ развели суффиксом. Ещё через два дня, 20 сентября, в parkings приехал коммит «отличать „нет данных“ от „мест нет“», и запись выросла:
'parking_id': occ.parking_id, 'city': getattr(spot, 'city', None) if spot else None, 'has_live_data': not silent, 'last_free_at': last_free.isoformat() if last_free else None, 'occupancy': occupancy, ...
Три новых поля. В сервисе прогнозов их нет до сих пор, он про них не знает.
Теперь представим, что суффикса :city не случилось и базы я бы не развёл. Запись сервиса прогнозов стала бы строгим подмножеством записи parkings, и каждые пять минут она затирала бы более полную версию урезанной.
Что бы на это сказала система. Модель ответа API:
class OccupancyResponseDTO(BaseModel): occupancy: List[dict] total: int
List[dict]. Список словарей. Полей не проверяет вообще, пропустит что угодно.
Фронтенд:
hasLiveData: parking.has_live_data ?? true,
Поле необязательное, а при его отсутствии подставляется true.
Сложим. Парковки с мёртвым датчиком отдают ноль свободных мест. Поле has_live_data существует ровно для того, чтобы такие показывать как «данных нет», а не как «мест нет». При затирании это поле пропадает, ?? true говорит «данные живые», и карта уверенно сообщает водителю, что мест нет, там где правда «неизвестно».
Ошибки нет. Исключения нет. Пятисотки нет. Половина ответов правильная, половина просто беднее, и какая тебе досталась, зависит от того, какой сервис последним обновил ключ.
Что я на самом деле сделал
Я защитился от коллизии, которая в тот момент была безвредной, и которая через два дня перестала быть возможной по не зависящей от меня причине.
Номер базы сегодня не защищает ни от чего. Если его вернуть на ноль, ключи всё равно разные.
Но правильным решение от этого быть не перестало, и вот почему. Защита номером базы не зависит от того, что происходит в чужом репозитории. Суффикс :city появился не ради развязки сервисов, а ради мультигородности. Его автор (снова я) решал свою задачу и не думал про прогнозы. Завтра кто-нибудь упростит ключ обратно, потому что городов снова станет один, и коллизия вернётся, не оставив следа ни в одном ревью.
Иначе говоря, сейчас эти два сервиса не сталкиваются благодаря совпадению, а не благодаря решению. Разница между этими состояниями не видна ни в одном из двух репозиториев.
Что здесь общего
Общий Redis это общее пространство имён, и оно связывает два кода строкой, которой нет ни в одном списке зависимостей. В requirements.txt прогнозов нет parkings. В ревью коммита про мультигородность нечему было насторожить: ключ меняется внутри своего сервиса, тесты зелёные.
Сам я эту коллизию заметил случайно, потому что перед переключением зачем-то посмотрел, что лежит в чужой базе. Если бы не посмотрел, переключил бы на нулевую, и ничего бы не сломалось ни в тот день, ни на следующий. Сломалось бы 20 сентября, в коммите, который вообще не про Redis.
И отдельно про то, что меня здесь больше всего смущает. Одинаковые записи в двух репозиториях это не свойство системы, а свидетельство того, что один файл когда-то скопировали из другого. Пока никто не трогал ни один из них, они совпадали, и совпадали почти год. Разошлись они за четыре дня, как только одному из сервисов понадобилось новое поле. Копия не знает, что она копия, и расходится молча.
Поэтому если два сервиса пишут в одно хранилище, разводить их стоит не тогда, когда данные разъехались, а до того. Отдельная база, префикс с именем сервиса, что угодно, что делает столкновение невозможным, а не маловероятным. Это стоит одной строчки в конфиге и снимает целый класс отладки, в котором нет ни ошибки, ни стектрейса, а есть только ответ, который чуть-чуть неправильный.
Данные, на которых всё это крутится, лежат открыто: 4,6 млн получасовых замеров загруженности парковок, CC BY 4.0.
