На днях я наткнулся на следующий фрагмент кода, который привлек мое внимание:

def post(self, request, args, kwargs):
    obj: Object = get_object_or_404(Object, id=kwargs['id']) # 1.
    with DistributedLock(obj): # 2.
        check_obj_ready(obj) # 3.
     
        cluster_config = ClusterConfig(obj) # 4.
        config = Config(cluster_config.read()) # 5.
        config.add(json.loads(request.data)['data']) # 6.
     
        if obj.limit < len(config.get_limit()): # 7.
            raise serializers.ValidationError(EXCEEDED)
     
        config.write(config.serialize()) # 8.
    
        return Response(
            data={
                "message": "Success",
            },
            status=status.HTTP_202_ACCEPTED,
        )

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

  1. Во-первых, объект извлекается из базы данных с помощью метода get_object_or_404.
  2. Затем мы применяем распределенную блокировку к этому объекту. Для этой цели мы могли бы использовать такую ​​систему, как Redis. Подразумевается, что сущность потенциально может быть изменена другим экземпляром приложения, что требует блокировки. Эта блокировка имеет решающее значение, когда мы извлекаем объект из базы данных, изменяем его в нашем приложении, а затем записываем обратно в базу данных. Это известная дилемма, известная как «Потерянное обновление». Но прежде чем мы углубимся в это, давайте продолжим анализ кода.
  3. После установки блокировки мы оцениваем, готов ли объект. Это просто включает проверку состояния определенного атрибута в объекте.
  4. Затем мы загружаем так называемую «конфигурацию кластера», где «кластер» относится к кластеру Kubernetes.
  5. Впоследствии мы считываем некоторые данные конфигурации из этого кластера, которые могут быть любыми ресурсами Kubernetes, такими как configmap или секрет. Для упрощения рассмотрите его как файл yaml, содержащий определенные детали конфигурации.
  6. Пользовательский ввод из тела запроса затем добавляется к этой конфигурации.
  7. Затем выполняется проверка предела объекта базы данных.
  8. И, наконец, обновленная конфигурация записывается обратно в ресурс Kubernetes.

Выявление ошибок параллелизма в приложении может оказаться довольно сложной задачей. Первое, что пришло в голову при внимательном изучении этого кода, — это реализация Mutex в Rust. Чтобы избежать гонок данных за объектом в Rust, объект помещается в мьютекс.

let obj = Mutex::new(Object::new())

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

Но реальный вопрос в том, действительно ли нам нужна распределенная блокировка? Ответ, как это часто бывает, зависит от ситуации. В этом случае было бы уместно использовать функцию надежной монопольной блокировки реляционной базы данных, чтобы смягчить параллельную проблему потерянных обновлений. Получение эксклюзивной блокировки сигнализируется ключевыми словами FOR UPDATE. Такой подход упрощает процесс и уменьшает количество требуемых компонентов. Затем код может быть переработан как:

@transaction.atomic
def post(self, request, args, kwargs):
    obj = Object.objects.raw("SELECT * FROM object_object where id=%s and ready=true FOR UPDATE", [kwargs["id"]) # 1.
 
    cluster_config = ClusterConfig(obj) # 4.
    config = Config(cluster_config.read()) # 5.
    config.add(json.loads(request.data)['data']) # 6.
 
    if obj.limit < len(config.get_limit()): # 7.
        raise serializers.ValidationError(EXCEEDED)
 
    config.write(config.serialize()) # 8.

    return Response(
        data={
            "message": "Success",
        },
        status=status.HTTP_202_ACCEPTED,
    )

Я поймал себя на вопросе, почему мы проверяли условие в коде, а не в запросе, поэтому я также интегрировал это в эту версию.

Впоследствии мы можем задаться вопросом, нужна ли вообще блокировка. Ответ по-прежнему зависит от характера системы и компромиссов, на которые мы готовы пойти. Единственный случай, когда нам нужен объект, — это шаг 7, где сравнивается предел. Если мы готовы время от времени проверять устаревшие предельные значения, то мы действительно можем обойтись без блокировки. Частота изменений предельного значения также может повлиять на это решение. Если он меняется редко, то блокировку можно снять. Но если в приоритете абсолютная точность, то блокировка должна быть сохранена.

Ресурсы Kubernetes предлагают еще один метод предотвращения потери обновлений. Каждый ресурс содержит поле «resourceVersion». Это поле представляет собой монотонно увеличивающийся счетчик, который увеличивается при каждом изменении ресурса. Это предлагает оптимистическую форму управления параллелизмом, которая контрастирует с пессимистичным управлением параллелизмом, достигаемым с помощью блокировок.

Представьте себе сценарий, в котором два потока, t1 и t2, пытаются изменить один и тот же ресурс, r1. Они оба загружают ресурс с сервера (KubeAPI), вносят изменения в память, а затем пытаются записать изменения обратно. В нашем сценарии t1 первым передаст свои изменения обратно на сервер. Затем сервер увеличивает версию ресурса до двух. Когда поток t2 пытается аналогичным образом обновить сервер, его запрос отклоняется. Это связано с расхождением в версии ресурса: запрос t2 идентифицирует версию ресурса r1 как 1, а сервер уже обновил ее до 2. Следовательно, запрос t2 отклоняется.

Этот подход можно отразить в реляционных базах данных с запросами, подобными:

obj = Object.objects.raw("SELECT * FROM object_object where id=%s", [kwargs["id"])
Object.objects.raw("UPDATE object_object SET version=version+1, limit=5 where id=%s AND version=%s", [obj.id, obj.version])

Тем не менее, важно помнить, что вы все еще можете столкнуться с проблемами параллелизма, как было указано ранее, с оптимистической блокировкой. Если вы сомневаетесь, выбирайте блокировки, помня о потенциальном влиянии на производительность.

Подводя итог, можно сказать, что управление многопоточностью и параллелизмом является сложным, но жизненно важным аспектом проектирования программного обеспечения. Решение об использовании таких методов, как «resourceVersion» Kubernetes или эксклюзивная блокировка реляционной базы данных, зависит от конкретных потребностей системы и компромиссов, на которые мы готовы пойти.

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

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

Я надеюсь, что это исследование потерянных обновлений было проницательным. Не стесняйтесь обращаться с вопросами или мыслями. За наш коллективный рост в обширной области разработки программного обеспечения.