JSON Patch в продакшене: атомарность, идемпотентность и конкурентные конфликты
Главное: JSON Patch — только формат изменения; атомарность, идемпотентность и параллелизм всё ещё на вас. Рассматривает предварительные условия test, экранирование путей, откат и координацию ETag.

Главное: сначала валидируйте целевые пути и разрешения патча, затем добавьте утверждение параллелизма через test или If-Match; применяйте патч к копии, чтобы сбой не оставил ничего полузаписанным.
Реальная опасность запроса
PATCH— не запись не того поля. Это вера в то, что запись удалась, пока она молча перезаписывает чужое изменение. JSON Patch даёт вам инструменты, но проектирование безопасности всё ещё ваше.
1. Быстрый обзор набора операций RFC 6902
JSON Patch описывает изменения как массив операций с медиатипом application/json-patch+json. Шесть операций выполняются в порядке массива:
| Операция | Эффект | Ключевое ограничение |
|---|---|---|
add |
Добавить член или вставить элемент массива | Индекс массива может равняться длине; - означает конец |
remove |
Удалить член или элемент | Целевой путь должен существовать |
replace |
Заменить существующее значение | Целевой путь должен существовать |
move |
Переместить узел | Нельзя переместить узел в его собственный дочерний путь |
copy |
Копировать узел | Исходный путь должен существовать |
test |
Утвердить равенство значений | Если падает, весь патч не применяется |
Многие помнят только первые три и забывают, что test превращает слепую запись в условную. Полная семантика операций и границы массивов рассмотрены в JSON Pointer, Patch и Merge Patch.
2. Использование test для превращения патча в условное обновление
Типичный патч оптимистичной блокировки начинается не с записи, а с утверждения версии:
{"op":"test","path":"/version","value":1}
Поместите его в полный патч:
[
{ "op": "test", "path": "/version", "value": 1 },
{ "op": "replace", "path": "/version", "value": 2 },
{ "op": "replace", "path": "/status", "value": "paid" }
]
Логика: подтвердить, что версия документа сейчас 1, затем изменить её на 2. Если другой запрос уже продвинул версию до 2, первый test падает, весь патч не применяется, и сервер возвращает конфликт. Это предотвращает потерянные обновления, не вводя распределённую блокировку.
Несколько моментов дизайна:
- Помещайте
testперед каждой записью. Если более ранняя запись уже вступила в силу, утверждение бессмысленно; - Утверждайте номер версии или дайджест, генерируемый сервером, а не обычное поле, которое клиент может свободно менять;
- Увеличивайте версию при каждой успешной записи; иначе два запроса с одной старой версией оба проходят;
valueвtestдолжен иметь точно тот же тип JSON, что и значение документа.1и"1"не равны.
3. Экранирование путей и загрязнение прототипа
Правила экранирования JSON Pointer просты: пишите ~ как ~0, а / как ~1, и заменяйте ~ перед /, иначе получите двойное экранирование.
Ключ "a/b" → /a~1b
Ключ "m~n" → /m~0n
Ключ "a~/b" → /a~0~1b
Риск с внешними патчами обычно не экранирование, а опасные ключи. В JavaScript ключи вроде __proto__, constructor и prototype могут вызвать загрязнение прототипа, даже через обычный add:
[
{ "op": "add", "path": "/__proto__/polluted", "value": true }
]
Используйте белый список, а не чёрный:
- Разрешайте патчам изменять только бизнес-поля, например
/statusили/items/0/quantity; - Валидируйте каждый
pathиfromпо регулярному выражению или префиксу пути, отвергая неожиданные уровни, кроме индексов массивов; - Разбирайте JSON в контейнеры
Object.create(null)илиMap, чтобы уменьшить влияние загрязнённой цепочки прототипов; - Ограничьте число операций и байтовый размер каждого
value.
4. Атомарное применение и откат
RFC 6902 требует атомарного выполнения, но атомарность — семантическое требование, а не умолчание в каждой библиотеке. Библиотеки вроде fast-json-patch мутируют документ на месте, когда mutateDocument равно true, и сбой может оставить частичные изменения.
import { applyPatch, validate } from 'fast-json-patch';
/**
* Apply a patch immutably and return the original document on failure.
* @param document The original document, which callers must not mutate
* @param patch The JSON Patch operation array to apply
* @returns The result, including a success flag and the new document
*/
function applySafely(document: unknown, patch: unknown[]) {
const errors = validate(patch, document);
if (errors) {
return { ok: false, document };
}
try {
const result = applyPatch(document, patch, false, false);
return { ok: true, document: result.newDocument };
} catch {
return { ok: false, document };
}
}
Три рекомендации по реализации:
- Сначала глубоко копируйте или структурированно клонируйте, применяйте к копии и меняйте ссылку только после успеха;
- Не полагайтесь на автоматический откат библиотеки; поведение при исключениях различается между реализациями;
- Валидируйте перед выполнением:
validateпроверяет только структуру, а семантические конфликты всё ещё требуютtest; ни один не заменяет другой.
Если патч охватывает несколько ресурсов, например изменение заказа и его запасов вместе, атомарность JSON Patch действует только в пределах одного документа. Между документами вам нужна транзакция базы данных или компенсирующие действия.
5. Идемпотентность: PATCH не идемпотентен по природе
HTTP-семантика определяет PUT и DELETE как идемпотентные, но PATCH не обязательно. Повторение одного и того же JSON Patch может дать совершенно разные результаты:
add /items/-добавляет в конец массива каждый раз, поэтому повторы создают дубликаты;replace /versionменяет версию 1 на 2 и succeeds снова при повторе, хотя операция уже была применена;remove /tempпадает при втором запуске, потому что путь больше не существует.
Есть три распространённых способа достичь идемпотентности:
| Подход | Как работает | Лучше для |
|---|---|---|
| Предусловие версии | Первая операция — test /version, затем инкремент |
Ресурсы с явной версией |
| Ключ идемпотентности | Клиент отправляет Idempotency-Key; сервер кеширует результат |
Платежи, заказы и другие записи |
| Патч целевого состояния | Используйте replace вместо add, чтобы результат зависел только от финального значения |
Конфигурация и поля статуса |
Худший шаблон — патч, смешивающий add и remove, при допущении, что повторы безопасны. Если патч что-либо добавляет или удаляет, он должен быть в паре с утверждением версии или ключом идемпотентности.
6. Конкурентные конфликты: ETag и If-Match
Помимо test внутри патча, HTTP предлагает стандартный механизм: сервер возвращает ETag, а клиент отправляет If-Match при следующем запросе.
GET /orders/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json
[{"op":"replace","path":"/status","value":"paid"}]
Если ресурс изменился, сервер возвращает 412 Precondition Failed, и клиент заново получает данные перед повтором. If-Match: * означает «выполнять, пока ресурс существует», что не подходит, когда нужно предотвратить перезаписи.
Разница между test и If-Match:
If-Matchпроверяется на слое HTTP до парсинга тела, поэтому дешевле;testпроверяется на семантическом слое патча и может утверждать любое поле, не завися от поддержки ETag на сервере;- Их можно сочетать, но не реализуйте только один из них и не заявляйте, что конечная точка безопасна при параллелизме.
7. Различия библиотек и выбор
| Реализация | Язык | Атомарность | Поддержка test |
Примечания |
|---|---|---|---|---|
fast-json-patch |
JavaScript | Опциональная (по умолчанию возвращает новый документ) | Да | Широкая экосистема; следите за mutateDocument |
jsonpatch |
Python | Откатывается при исключениях | Да | Близко к стандарту, с ясными ошибками |
json-patch |
Go | Работает с копией | Да | Часто используется вместе с JSON Merge Patch |
zjsonpatch |
Java | Да | Да | Удобная интеграция с Jackson |
Не выбирайте библиотеку только по «поддерживает все шесть операций». Подтвердите три вещи: откатывается ли сбой, сравнивает ли test строго и поднимает ли add массива вне диапазона ошибку. Слабые реализации заставляют один и тот же патч вести себя по-разному в разных средах.
8. Контрольный список безопасности перед запуском
- [ ] Конечная точка объявляет
application/json-patch+json; - [ ] Патчи применяются к копии, и сбой никогда не загрязняет исходный документ;
- [ ] Каждый
pathиfromво внешних патчах проходит проверку белого списка; - [ ] Опасные ключи вроде
__proto__,constructorиprototypeотвергаются; - [ ] Число операций и размер одного значения ограничены;
- [ ]
testилиIf-Matchобеспечивает контроль параллелизма; - [ ] Патчи записываются в журнал аудита с действующим лицом, меткой времени и полным массивом операций;
- [ ] Границы транзакций явны, когда патч охватывает документы.
Патч по природе является полным описанием «что изменилось», что делает его естественной записью аудита. О кодах ошибок и идемпотентности на слое API продолжайте в Дизайне JSON API. Если патч происходит от слияния двух веток, вам также нужна семантика конфликтов, рассмотренная в Слиянии Git для JSON-файлов.
Вопросы и ответы
Какой код статуса должен возвращать провалившийся test?
Если конфликт происходит от утверждения версии, и 409 Conflict, и 412 Precondition Failed защитимы. Предпочитайте 412, чтобы оставаться согласованным с If-Match, и включите текущую версию в тело ответа, чтобы клиент мог повторить.
Можно ли безопасно повторять патч, содержащий add и remove?
Не напрямую. add в конец массива дублирует данные, а remove падает при втором запуске, потому что путь исчез. Добавьте предусловие версии или ключ идемпотентности, чтобы повторы распознавались как одна операция.
Может ли JSON Patch гарантировать транзакции между документами?
Нет. Атомарность RFC 6902 применяется к одному документу JSON. Когда участвуют заказы, запасы или другие ресурсы, используйте транзакции базы данных, компенсацию Saga или очередь сообщений для согласованности.
Почему патч никогда не должен писать в __proto__?
Потому что большинство реализаций JavaScript хранят документ в обычных объектах, и запись __proto__ может изменить цепочку прототипов и влиять на каждый объект впоследствии. Белый список путей — самая надёжная защита; не полагайтесь только на внутреннюю фильтрацию библиотеки.
Как сделать запрос PATCH идемпотентным?
Есть три распространённых подхода: начните патч с test /version и инкрементируйте версию при успехе, заставьте сервер кешировать результат для Idempotency-Key или используйте replace вместо add, чтобы результат зависел только от финального значения. Если патч что-либо добавляет или удаляет, он должен быть в паре с утверждением версии или ключом идемпотентности; его нельзя просто повторять.
Стоит ли использовать и test, и If-Match?
Не обязательно использовать оба, но реализация только одного из них не делает конечную точку безопасной при параллелизме. If-Match проверяется на слое HTTP до парсинга тела, поэтому дешевле; test проверяется на семантическом слое патча и может утверждать любое поле, не завися от поддержки ETag на сервере.