Об ошибках, возникающих ниоткуда и в которых некого винить: Феномен Размазывания Ответственности
В статье не пойдет речь о безответственных сотрудниках, как можно было бы предположить по заголовку статьи. Мы обсудим одну реальную техническую опасность, которая возможно поджидает и вас, если вы создаёте распределённые системы.
В одной Enterprise системе жила-была компонента. Эта компонента собирала данные от пользователей о неком продукте и записывала их в банк данных. И состояла она из трёх стандартных частей: пользовательского интерфейса, бизнес-логики на сервере и таблицы в банке данных.
Работала компонента хорошо, и несколько лет к её коду никто не притрагивался.
Но вот однажды, ни с того ни с сего, начали твориться с компонентой странные дела.
Работая с некоторыми пользователями, компонента посреди сеанса вдруг начинала выбрасывать ошибки. Происходило это нечасто, но как водится, в самый неподходящий момент. И что самое непонятное, первые ошибки появились в стабильной версии системы в production. В версии, в которой несколько месяцев вообще никакие компоненты не менялись.
Начали анализировать ситуацию.Проверили компоненту под большой нагрузкой. Работает хорошо. Повторили достаточно объёмные интеграционные тесты. В интеграционных тестах наша компонента сработала нормально.
Одним словом, ошибка приходила непонятно когда и непонятно откуда.
Стали копать глубже. Детальный анализ и сопоставление лог-файлов показали, что причиной сообщений об ошибке, показываемых пользователю, является constraint violation в primary key в уже упомянутой таблице в банке данных.
Компонента писала данные в таблицу с помощью Hibernate, и иногда Hibernate при попытке записать очередную строчку заявляла о constraint violation.
Не стану утомлять читателей дальнейшими техническими деталями и сразу расскажу о сути ошибки. Оказалось что в вышеупомянутую таблицу пишет не только наша компонента, но иногда (крайне редко) некая другая компонента. И делает она это совсем просто, с помощью простого SQL INSERT statement. A Hibernate работает по умолчанию при записи следующим образом. Чтобы оптимизировать процесс записи, она запрашивает в индексе значение следующего primary key один раз, и после этого записывает несколько раз просто увеличивая значение key (по умолчанию — 10 раз). И если случилось так, что после запроса вторая компонента «встревала» в процесс и записывала данные в таблицу, используя при этом следующее значение primary key, то следовавшая за ней попытка записи со стороны Hibernate приводила к constraint violation.
Если вас интересуют технические детали, посмотрите их внизу
Код класса начинался примерно так:
@Entity
@Table(name="PRODUCT_XXX")
public class ProductXXX {
@Id
@Basic(optional=false)
@Column(
name="PROD_ID",
columnDefinition="integer not null",
insertable=true,
updatable=false)
@SequenceGenerator(
name="GEN_PROD_ID",
sequenceName="SEQ_PROD_ID",
allocationSize=10)
@GeneratedValue(
strategy=GenerationType.SEQUENCE,
generator="GEN_PROD_ID")
private long prodId;
Одно из обсуждений схожей проблемы на Stackoverflow:
https://stackoverflow.com/questions/12745751/hibernate-sequencegenerator-and-allocationsize
И уж так вышло, что в течение долгих месяцев после изменения второй компоненты и реализации в ней записи в таблицу, процессы записи первой и второй компоненты по времени никогда не пересекались. А пересекаться они начали, когда в одном из подразделений, использующих систему, несколько изменился график работы.
Ну, а интеграционные тесты прошли гладко, поскольку интервалы времени тестирования обоих компонент внутри интеграционных тестов тоже не пересеклись.
В известном смысле можно сказать, что в появлении ошибки действительно никто не был виноват. Или всё же это не так?
Наблюдения и размышления
После обнаружения истинной причины ошибки она была исправлена.
Но не этим happy end я хотел бы закончить эту статью, а поразмышлять над данной ошибкой как над представителем обширной категории ошибок, приобретших популярность после перехода от монолитных к распределённым системам.
С точки зрения отдельных компонент или сервисов в описанной Enterprise-системе всё было сделано все вроде бы правильно. Все компоненты, или сервисы, имели независимые друг от друга жизненные циклы. И когда во второй компоненте возникла необходимость записи в таблицу, из-за незначительности операции было принято прагматическое решение реализовать это прямо в данной компоненте самым простейшим способом, а не трогать стабильно работающую первую компоненту.
Но увы, произошло то, что часто происходит в распределённых системах (и относительно реже в монолитных системах): ответственность за выполнение операций над определённым объектом была размазана между подсистемами. Наверняка, если бы обе операции записи были реализованы в одном и том же микросервисе, для их реализации была бы выбрана единая технология. И тогда описанная ошибка не возникла бы.
Распределенные системы, особенно концепция микросервисов, эффективно помогли решить ряд проблем, присущих монолитным системам. Однако, как это не парадоксально, разнесение ответственности по отдельным сервисам провоцирует обратный эффект. Компоненты теперь «живут» по возможности независимо друг от друга. И неизбежно возникает соблазн, внося большие изменения в одну компоненту, «привинтить прямо здесь» немного функциональности, которую лучше бы реализовать в другой компоненте. Этим быстрее достигается конечный эффект, сокращается объем согласований и тестирования. Так, от изменения к изменению, компоненты обрастают несвойственными им фичами, одни и те же внутренние алгоритмы и функции дублируются, возникает многовариантность решения задач (а иногда — и их недетерминированность). Другими словами, распределенная система деградирует со временем, но иначе, чем монолитная.
«Размазывание» ответственности по компонентам в больших системах, состоящих из многих сервисов — одна из из типичных и болезненных проблем современных распределенных систем. Ещё больше ситуацию усложняют и запутывают совместно используемые подсистемы оптимизации типа кэширования, прогнозирования следующих операций (prediction), а также оркестрирования сервисов и т.д.
Централизация доступа к базе данных, хотя бы на уровне единой библиотеки, требование достаточно очевидное. Однако, многие современные распределенные системы исторически выросли вокруг баз данных и используют хранимые в них данные напрямую (через SQL), а не через сервисы доступа.
«Помогают» размазыванию ответственности и ORM фреймворки и библиотеки типа Hibernate. Используя их, многим разработчикам сервисов доступа к базам данных невольно хочется отдавать в качестве результата запроса по возможности полноценные объекты. Типичным примером является запрос пользовательских данных с целью показать их в приветствии или в поле с результатом аутентификации. Вместо возврата имени пользователя в виде трёх текстовых переменных (first_name, mid_name, last_name) по такому запросу нередко возвращается полноценный пользовательский объект с десятками атрибутов и связных объектов, типа списка ролей запрошенного пользователя. Это в свою очередь усложняет логику обработки результата запроса, порождает ненужные зависимости обработчика от типа возвращенного объекта и… — провоцирует размазывание ответственности за счет возможности реализации связанной с объектом логики извне ответственного за этот объект сервиса.
А что делать? (Рекомендации)
Увы, размазывание ответственности в определённых случаях иногда вынуждено, а порой даже неизбежно и оправдано.
Тем не менее, если возможно, надо стараться соблюдать принцип распределения ответственности между компонентами. Одна компонента — одна ответственность.
Ну, а если уж невозможно сосредоточить операции над определёнными объектами строго в одной системе, такое размазывание необходимо очень тщательно регистрировать в общесистемной («надкомпонентной») документации, как специфическую зависимость компонент от элемента данных, от доменного объекта или друг от друга.
Было бы интересно узнать ваше мнение на этот счет, а также случаи из практики, подтверждающие или опровергающие тезисы этой статьи.
Благодарю вас за то, что вы дочитали статью до конца.
Иллюстрация «Мультимеда михер» автора статьи.