Кто прав, если оба правы: последняя запись, слияние по полям и часы
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Протокол «Подхода» умеет уже почти всё: имена операций с телефона, курсор сервера, память вердиктов, надгробия. Молчит он про один случай, и случай этот самый обычный. Подход, записанный утром в зале, вечером правят в веб-версии с дивана, а телефон в это время лежит без сети со своей правкой того же подхода. Обе правки настоящие, обе сделал один человек, обе доедут до сервера. Кто победит? Ответ у кода есть всегда, даже когда правило не выбрано: побеждает тот, кто пришёл последним, — и это, как выяснится, худшее из возможных правил. Статья про три следующих — последнюю запись, слияние по полям и слияние по вкладам — и про часы устройства, из-за которых «последняя» вовсе не значит «поздняя».
Две копии дневника до сих пор жили мирно: каждая правка приходила в своё время и ложилась на строку. Так выходило потому, что правил всегда кто-то один. Сегодняшний вечер другой: в зале без сети человек поправил в подходе повторы, а дома в веб-версии — вес того же подхода. Ни одна правка не ошибочна, ни одну нельзя отбросить: обе сделал он сам.
Сравним два способа возить изменения. Первый — наш: операция несёт только изменённые поля, и обе стороны накладывают её на строку («Журнал: имена и номера рождаются на телефоне»). Второй — самый распространённый в обычных API: устройство шлёт строку целиком с отметкой времени, а сервер оставляет ту, что свежее. Это и есть «последняя запись побеждает», last write wins. Прогноз: что окажется у сервера в каждом случае?
Операциями по полям сохранились обе правки: вес 85 с веб-версии и повторы 8 с телефона. Последняя запись строкой оставила строку веб-версии целиком — и вместе с ней вернула прежние повторы 5. Правка, сделанная в зале, исчезла без единого сообщения, причём проиграла она не другой правке того же поля, а просто оказалась внутри строки, которая приехала чуть раньше.
Соблазн последней записи понятен: сравнивать нечего, хранить нечего — одна отметка на строку, и сервер даже не обязан знать, что в строке лежит. Так устроена целая семья движков синхронизации — те, что возят состояние, а не операции; с одним из них, WatermelonDB, читатель встретится в конце главы. Плата за простоту ровно та, что в прогоне: чем крупнее единица сравнения, тем больше чужой работы уносит победитель.
«Последняя запись побеждает» — это не правило разрешения конфликтов, а отказ его иметь. Конфликта в нём нет по определению: побеждает не правка, а строка, и вопрос «что человек на самом деле менял» просто не задаётся.
Пока поля разные, слияние по полям выглядит всемогущим. Посмотрим на случай, где оно бессильно: оба поправили одно и то же поле. В зале человек дожал десятый повтор и записал это, а дома вспомнил, что повторов было одиннадцать, и поправил в веб-версии.
Одно из двух значений проиграет — это неизбежно, и само по себе не беда. Беда в другом, и прогноз про неё: что покажет телефон, что покажет веб-версия и что окажется у сервера после того, как оба синхронизируются дважды?
Три ответа на один вопрос: у телефона одиннадцать, у веб-версии десять, у сервера одиннадцать. Дневник разошёлся, и следующая синхронизация его не сведёт: новых операций нет, а старые каждый уже применил. При этом никто не ошибся и ничего не потерялось — обе правки лежат в журнале сервера целыми.
Причина — порядок. Оба устройства сделали одно и то же: своя правка легла в момент нажатия, а своя же, вернувшаяся с курсора, была отсеяна фильтром эха («Цикл синхронизации»), — так что последней у каждого оказалась чужая. Отсюда и зеркальный итог: телефон остался с одиннадцатью, веб-версия с десятью. Правило «побеждает пришедший последним» зависит от порядка прибытия, а порядок прибытия у каждого устройства свой.
Значит, правило должно быть не про очередь, а про сравнение: один и тот же набор операций обязан давать один и тот же итог, в каком бы порядке они ни пришли. Сравнивать есть чем — у операции есть время устройства at и имя opId. Таблица полей в главе 6 обещала, что at понадобится «правилу конфликтов»; пора его написать. Отметка ставится не на строку, а на каждое поле: изменилось поле — запомнили, чья правка его поставила.
Возьмём те самые три операции из журнала сервера и свернём их дважды: в порядке сервера и в том, в каком их видела веб-версия. И каждый раз — двумя правилами, старым «по прибытии» и новым «по отметкам». Прогноз: какие четыре числа напечатаются?
at, и побеждает она независимо от того, когда её применили. Это и есть нужное свойство: правило перестановочно, итог от порядка не зависит.Две правки легко получают одинаковый at: в миниатюре часы — счётчик, а на телефонах одна миллисекунда — это один тик системного таймера, и попасть в него вдвоём проще, чем кажется. Что тогда решит сравнение «строго новее»? Ничего: остаётся та правка, которую применили первой, и мы снова зависим от порядка. Прогноз: что покажут два правила — «только время» и «время, потом opId»?
Без второго ключа устройства разошлись опять, теперь на равном времени. С opId порядок стал полным: у любых двух правок есть однозначно старшая, потому что имена операций уникальны — «устройство плюс счётчик» из главы 6. Какое из имён считать старшим, не важно; важно, что ответ одинаков у всех.
Правило целиком — семь строк, и дальше оно лежит в подложке, общее для телефона и сервера. Общее не для красоты: если стороны складывают операции по-разному, строки разъедутся снова, просто медленнее.
Отметки полей никуда не едут. По сети идут те же операции, что и раньше, — opId, rowId, fields, at, — а отметки каждая сторона считает сама, пока их применяет. Контракты sync.push и sync.pull от этой статьи не меняются ни на одно поле.
Вся сила нового правила — в поле at, и пора сказать, откуда оно берётся: это часы устройства. В главе 6 они уже соврали однажды — идентификатор, собранный из показаний часов, поставил операции двух телефонов не в том порядке, в каком их создали («Журнал: имена и номера рождаются на телефоне»). Там это стоило веры в порядок. Здесь это стоит правки.
Сценарий самый обычный. Дома поправили вес, телефон эту правку забрал и показал. В зале человек, увидев её, ставит свой вес — то есть правит позже и в ответ на увиденное. Но часы телефона отстают: его счётчик показывает 7 при увиденной отметке 10 (на настоящем телефоне это пять минут отставания). Прогноз: какой вес останется после слияния и что изменит гибридное правило?
Правка с отстающих часов проиграла той, на которую отвечала: вес остался 85. Посмотрите на это глазами человека — он поставил 82,5, экран показал 82,5, а через минуту, после синхронизации, вернулось 85. Это худший вид потери: не «не сохранилось», а «сохранилось и откатилось само».
Лечится это на клиенте и двумя строками. Отметка берётся не прямо с часов, а как max(свои часы, самое позднее увиденное) + 1: раз мы видели отметку 10, наша следующая правка обязана быть новее десяти. Такие часы называют гибридными — в них есть и настоящее время, и счётчик событий. В прогоне отметка стала 11, и правка победила.
Честная оговорка: гибридные часы чинят ровно то, что человек видел. Правка, сделанная вслепую — телефон неделю пролежал без сети и про чужую ничего не знал, — по-прежнему сравнивается голыми часами и при отставании проиграет. Никакое правило этого не исправит: у двух устройств, ничего не знавших друг о друге, общего времени просто нет, и «кто раньше» — вопрос без ответа.
Отстающие часы теряют одну правку — свою. Спешащие теряют чужие, и не одну: пока чужие отметки ниже, побеждает всё, что написано с убежавших часов. Прогноз: какой вес останется после двух поздних правок с веб-версии и что будет с третьей, сделанной уже после того, как её часы увидели отметку 1000?
Вес 100 пережил обе поздние правки — только потому, что у телефона в тот вечер часы показывали на тысячу делений вперёд. Зато первая же синхронизация подтянула чужие часы гибридным правилом, и следующая правка встала выше. Вот зачем гибридные часы нужны на всех устройствах: они не делают время правильным, но ограничивают вред вруна тем, что он успел написать до встречи с остальными.
В цикле синхронизации это две строки: одна при записи — now() вместо Date.now(), другая на приёме страницы. Обе уезжают в подложку и работают до конца части.
Правильного времени у телефона нет и не будет: часовой пояс меняется в самолёте, NTP подтягивает часы скачком назад, а человек переводит их руками, чтобы обмануть игру. Поэтому протокол не пытается узнать, «когда было на самом деле». Он добивается меньшего, но достижимого: чтобы на вопрос «кто новее» все устройства отвечали одинаково.
Подложка теперь полная: то же хранилище, тот же журнал, тот же сервер-партнёр — плюс слияние по полям и гибридные часы на обеих сторонах. Повторим сценарий, с которого начался разговор про одно поле: оба устройства правят повторы без сети. Прогноз: что покажут телефон, веб-версия и сервер, какие отметки достанутся двум правкам и что будет с третьей, ответной?
Все трое показывают одиннадцать — расхождения нет. А отметки у первых двух правок одинаковые, at 2 у каждой: оба устройства писали свою первую правку по своим часам, и счётчики совпали. Спор решил второй ключ — B:1 старше A:2 по имени. Зато ответная правка веб-версии получила отметку 3: гибридные часы подняли её выше увиденного, и победила она уже по времени, без всяких ничьих.
Что из этого видит человек? Ничего. Правило работает ниже разметки, и экран про конфликты не знает: он подписан на хранилище через useSyncExternalStore — «Свой хук: логика без разметки» части IV, заново это не объясняется. Прогноз: сколько раз отрисуется экран подхода и что он напечатает, если сначала нажать «+2,5 кг», а потом получить с сервера чужую правку повторов?
Три отрисовки, и обе правки на месте: свой вес, чужие повторы. Ни состояния «конфликт», ни диалога, ни спиннера — строка меняется так же, как менялась бы от собственного нажатия. Это стоит держать в голове как проверку: если экрану приходится спрашивать «чью версию оставить», значит, правило не выбрано и его перекладывают на человека.
А если всё-таки спрашивать, то в React Native это отдельная работа: своим диалогом на Modal, потому что штатный Alert в веб-сборке — пустой метод (глава 4). И задавать вопрос придётся тому устройству, которое первым увидело обе правки, — а это не обязательно то, что сейчас в руках.
Осталась форма данных, которой слияние по полям не помогает вовсе, — счётчик. В «Подходе» это, например, число подходов за неделю. В прошлой статье мы договорились не слать «прибавь один»: повтор такой операции считает лишнее. Значит, оба устройства шлют «стало столько» — и вот тут выясняется цена этой договорённости.
До зала подходов было три. В зале человек сделал ещё один, телефон записал «стало 4». Дома, ничего не зная про зал, он сделал ещё один в веб-версии — «стало 4». Прогноз: что даст слияние по полям и что даст другая форма хранения, где в строке лежит не итог, а вклад каждого устройства?
Слияние по полям дало четыре. Обе правки победить не могут, а пишут они одно и то же число, так что победитель ничего не добавляет к проигравшему. Заметьте, что ничего и не «потерялось по ошибке»: потерялась прибавка, которой в записи просто нет — «стало 4» не помнит, что до него было три.
Вклады чинят это сменой того, что хранится: в строке не число, а по счётчику на устройство, а итог — их сумма. Слияние тогда — максимум по каждому устройству, три строки кода. В прогоне видно, что ведёт оно себя правильно при любом обращении: пять в прямом порядке, пять в обратном, пять при повторном слиянии той же пачки. Три свойства, ради которых всё затевалось: перестановочность (порядок не важен), сочетательность (можно сливать по частям — у max это даром) и идемпотентность (повтор ничего не меняет).
Это и есть идея CRDT — бесконфликтно сливаемых типов данных — на уровне понимания: не хранить значение, которое придётся выбирать, а хранить то, что сливается само. Настоящие реализации делают то же самое для текста, списков и деревьев: @automerge/automerge 3.5.0 и yjs 13.6.33. API здесь намеренно не показан — в «Подходе» он не нужен, а пересказ чужого интерфейса по памяти ничему не учит. Эссе Ink & Switch про local-first, с которого началась глава 6, стоит как раз на CRDT и Automerge; а вот критика «последней записи» из этой статьи — наша, в эссе её нет.
| правило | что делает с правками | чем платит |
|---|---|---|
| последняя запись строкой | оставляет одну строку целиком | теряет чужую правку, даже сделанную в другом поле |
слияние по полям с отметкой [at, opId] | сохраняет правки разных полей, у одного поля берёт новейшую | хранит отметку у каждого поля и зависит от часов устройства |
| вклады устройств | не теряет ни одной прибавки | значение превращается в набор чисел и растёт с числом устройств |
| спросить человека | не теряет ничего | вопрос и экран для него; в дневнике одного человека это перебор |
Цена вкладов — рост: строка помнит столько чисел, сколько устройств у человека было за всё время, и вычеркнуть из неё потерянный телефон нельзя — итог уменьшится. Для дневника с двумя-тремя устройствами это ничто, для документа с сотней соавторов — отдельная работа, которой и заняты настоящие библиотеки CRDT.
- Слияние по полям сохраняет обе правки, если правили разное; «последняя запись побеждает» уносит чужую правку целиком — в прогоне повторы 8 исчезли вместе с проигравшей строкой.
- Когда правили одно поле, кто-то проигрывает всегда, и это нормально. Ненормально другое: без явного правила устройства расходятся молча — телефон 11, веб-версия 10, сервер 11, и свести их уже нечем.
- Правило обязано быть перестановочным: отметка
[at, opId]у каждого поля даёт один и тот же итог в любом порядке применения.opId— обязательный второй ключ, иначе при равном времени расхождение возвращается. at— часы устройства, и они врут. Отстающие проигрывают правку, сделанную позже; спешащие выигрывают у всех поздних правок. Гибридные часыmax(своё, увиденное) + 1чинят то, что человек видел, и только это.- Счётчик слиянием по полям не лечится: два «стало 4» дают 4, а подходов пять. Лечится сменой того, что хранится: вклады устройств, слияние максимумом по каждому, итог — сумма; перестановочно, сочетательно, идемпотентно.
- Это идея CRDT на уровне понимания; настоящие реализации — Automerge и Yjs, и эссе Ink & Switch стоит на них. Критика «последней записи» здесь наша: в эссе её нет.
1/4Телефон поправил в подходе повторы, веб-версия — вес. Почему «последняя запись побеждает» всё-таки теряет одну из правок?
2/4Оба устройства поправили одно и то же поле, обе операции доехали до сервера, ни одна не потерялась. Почему телефон и веб-версия показывают разное?
3/4Телефон забрал чужую правку с отметкой 10, показал её человеку, и человек тут же поправил это поле у себя. Часы телефона показывают 7. Какую отметку должна получить его правка?
4/4Оба устройства добавили по подходу и записали «стало 4». Слияние по полям даёт 4, хотя подходов сделано 5. Что с этим делать?
Правило, которое сравнивает только время
Правило слияния написано, и на разном времени оно работает. Но две правки одного поля на разных телефонах попали в одну миллисекунду — и устройства снова показывают разное. Почините newer так, чтобы у любых двух правок нашлась однозначно старшая, одна и та же у всех.
Сравнение по времени надо дополнить вторым ключом — именем операции:
Вывод становится сервер видит 11 | веб-версия видит 11.
Какое из имён считать старшим — вопрос вкуса: важно, что opId уникален и сравнивается одинаково везде. Без второго ключа правило перестаёт быть правилом ровно там, где оно нужнее всего: при равном времени побеждает тот, кто успел применить первым, а это у каждого устройства своё. Вместе at и opId дают полный порядок — любые две правки сравнимы, и ответ один на всех.
Гибридные часы, забывшие про единицу
Телефон забрал чужую правку с отметкой 10 и в ответ поставил свой вес; его собственные часы показывают 7. Отметку он считает гибридным правилом, но одной детали в нём не хватает. Посмотрите, чем это кончилось для ответной правки, и почините расчёт.
К максимуму нужно прибавить единицу — иначе ответная правка встаёт вровень с той, на которую отвечает:
Вывод становится отметка ответной правки: 11 | weightKg 82.5.
Без единицы отметки совпадают, и спор решает второй ключ, opId. Здесь он оказался не на стороне телефона, но дело не в этом: даже выиграв, такая правка выиграла бы случайно. Единица выражает простую вещь — то, что сделано после увиденного, обязано быть новее увиденного. На этом гибридные часы и стоят: время в них не только измеряется, но и считается.
Вклады, которые складываются
Счётчик подходов хранит вклады устройств, но слияние написано сложением. Пока пачка приходит один раз, итог похож на правду. Посмотрите, что делает с ним повторное слияние, и почините mergeCounts.
Вклад устройства — это не прибавка, а его текущий счёт, поэтому сливать надо максимумом:
Вывод становится слияние: 5 | в обратном порядке: 5 и та же пачка пришла второй раз: 5.
Сложение выглядит естественно («вклады же складываются»), но складывает оно не подходы, а копии одного и того же счёта: телефон свой вклад уже учёл, веб-версия — свой. Главное здесь вторая строка вывода: при сложении итог зависит от числа слияний, а слияний в синхронизации ровно столько, сколько пришло пачек, — и потерянный ответ из прошлой статьи делает их лишними. У максимума это свойство даром: слить одно и то же дважды — то же самое, что слить один раз.