Разработка / ROBLOX
UpdateAsync в Roblox: почему два сохранения могут потерять одно изменение
Разбираем вымышленный счётчик мастерской: два сервера прочитали 100, каждый добавил 5, а в хранилище осталось 105. Покажем различие между заменой значения и преобразованием свежего состояния, составим журнал конфликта и проверим чистую функцию локально.
Не начинай с обвинения кнопки сохранения #
Представь общий учебный счётчик доставок. Два независимых обработчика должны добавить по пять единиц. На экране каждый сообщает о выполненной работе, но итоговое значение увеличилось только один раз. Такой симптом может появиться, если оба обработчика рассчитали новую запись из одинаковой старой копии. Он не доказывает конкретную причину в твоей игре: сначала нужен журнал действий и условий записи.
Этот материал продолжает тему первого сохранения, но решает другую задачу — зависимость новой записи от значения, которое мог изменить другой сервер. Мы используем условный ключ учебного счётчика, а не кошелёк игрока, покупку или полный профиль. Все числа и последовательности ниже придуманы для объяснения. Два настоящих Roblox-сервера в рамках этой статьи не запускались.
Нарисуй последовательность до исправления #
Запиши пять строк: в хранилище 100; обработчик A читает 100; обработчик B читает 100; A записывает 105; B записывает свои 105. Во второй записи нет намерения украсть прогресс. Просто B заменяет значение результатом расчёта, выполненного до изменения A. После двух прибавлений ожидается 110, но две одинаковые замены оставляют 105.
Сохрани этот рисунок рядом с задачей. Он помогает различить «обработчик вообще не вызвался», «вызов завершился ошибкой» и «оба вызова использовали устаревшее основание». Добавь в журнал название операции, идентификатор учебного задания, старое значение и предлагаемый результат. Не собирай пароли или приватные данные ради диагностики; для локального упражнения достаточно вымышленных идентификаторов.
| Шаг | Действие | В хранилище |
|---|---|---|
| 1 | Начало | 100 |
| 2 | A читает 100 | 100 |
| 3 | B читает 100 | 100 |
| 4 | A записывает 105 | 105 |
| 5 | B записывает 105 | 105 |
Замена и преобразование — разные намерения #
SetAsync задаёт значение ключа без предварительного чтения в рамках этого метода. Такой выбор соответствует намерению заменить запись заранее известным значением, которое не зависит от её прошлого состояния. Если же требуется прибавить число к текущему счётчику, основание расчёта имеет значение. Документация Roblox рекомендует UpdateAsync для изменений, зависящих от текущего состояния, и при возможной записи из нескольких серверов.
Нельзя исправить пример простой заменой имени метода, оставив внутри callback возврат заранее вычисленного 105. В таком варианте результат по-прежнему получен из старой копии. Преобразование должно использовать current, переданный самому callback. Объясни это словами прежде, чем переносить решение на таблицу инвентаря: «предложить новую запись на основании свежего значения» отличается от «повторить мой старый снимок».
Callback может быть вызван повторно #
Если другой сервер изменил ключ между чтением значения и попыткой записи, UpdateAsync может вызвать функцию преобразования снова и отбросить прежний результат. Поэтому один вызов метода не означает один запуск callback. В нашем условном порядке A предлагает 105, B успевает сохранить 105, а затем A рассчитывает новое предложение уже из 105 и получает 110.
Повтор функции преобразования не должен заново выдавать предмет, отправлять наградное событие или изменять внешний объект игры. Callback рассчитывает предлагаемую запись. Игровые последствия требуют отдельного решения и подтверждённого состояния. Даже перенос выдачи предмета после успешного вызова сам по себе не доказывает, что одна бизнес-операция не будет повторно запрошена другим обработчиком.
Учебная функция без скрытого состояния #
Наш ModuleScript CounterTransform содержит Add(current, delta). Для отсутствующей записи он использует ноль, принимает целые неотрицательные значения и положительную прибавку, ограничивает результат миллионом. Эти правила выбраны для учебного счётчика. В другой игре допустимые значения и начальное состояние могут отличаться; копировать их как универсальные правила профиля нельзя.
Функция возвращает новое число и не меняет внешние переменные. На одинаковых входах она даёт одинаковый результат. Переданное неверное значение, отрицательная прибавка, дробь или превышение ограничения возвращают nil. У функции нет сети, ожиданий, выдачи наград и обращения к игроку. Перед публикацией мы проверили её локальным Luau-тестом, но это не проверка реального Roblox-хранилища.
-- Pure transform for an isolated teaching counter, not a player profile.
local Transform = {}
local LIMIT = 1000000
local function validInteger(value)
return type(value) == "number" and value == math.floor(value)
and value >= 0 and value <= LIMIT
end
function Transform.Add(current, delta)
if not validInteger(delta) or delta == 0 then return nil end
if current == nil then current = 0 end
if not validInteger(current) then return nil end
if delta > LIMIT - current then return nil end
return current + delta
end
return Transform
Где подключить преобразование #
В отдельной тестовой experience помести CounterTransform в ServerScriptService. Серверный Script получает DataStoreService, выбирает изолированное хранилище учебного счётчика и подключает модуль через require. Внутри UpdateAsync передай callback, который возвращает CounterTransform.Add(current, 5). Не вставляй в него task.wait, загрузку ресурса или другой вызов, который может приостановить выполнение.
Сам вызов UpdateAsync является сетевой операцией: оберни его в pcall и отдельно проверь возвращённое значение. Успешное завершение pcall говорит об отсутствии пойманной ошибки. Если callback отменил запись через nil, это не подтверждение сохранения новой пятёрки. Имя учебного хранилища не делает тест автоматически безопасным, если ты всё равно работаешь в production experience с реальными ключами.
-- Server Script in an isolated test experience only.
-- This demonstrates one numeric teaching key, not a player profile.
local DataStoreService = game:GetService("DataStoreService")
local CounterTransform = require(
game:GetService("ServerScriptService"):WaitForChild("CounterTransform")
)
local store = DataStoreService:GetDataStore("GuidebookCounterExercise_v1")
local ok, result = pcall(function()
return store:UpdateAsync("CounterExercise", function(current)
return CounterTransform.Add(current, 5)
end)
end)
if not ok then
warn("Write response failed; outcome is not established", result)
elseif result == nil then
warn("Update cancelled; no new saved counter was returned")
else
print("Updated teaching counter", result)
end
Отмена не должна становиться обнулением #
Возврат nil из callback отменяет обновление. В упражнении это полезно, когда текущая запись имеет неожиданный тип или новое число выходит за предел. Не заменяй повреждённую строку нулём только ради продолжения: так можно скрыть проблему данных и переписать значение, которое требует исследования. Отсутствующая запись и запись неправильного формата — разные случаи.
Для наблюдаемой диагностики зафиксируй, что результат не соответствует ожидаемому сохранённому числу, и останови дальнейшие действия этого учебного маршрута. Наша чистая функция не сохраняет подробный текст причины; это ограничение компактного примера. Если нужен журнал причин отказа, проектируй его так, чтобы повтор callback не становился повторной игровой операцией и не менял основание следующего расчёта.
Проверка на бумаге и локальный тест #
Сначала повтори плохой порядок со старой копией: прочитать 100 дважды, вычислить два раза 105, записать обе замены. Затем повтори хороший порядок пересчёта: подготовить предложение A, применить изменение B, отбросить старое предложение A и рассчитать его из 105. Последняя запись должна содержать 110. Это детерминированная модель двух порядков, а не эмуляция всех внутренних правил Roblox.
Отдельно проверь отсутствие записи, нормальное число, строку вместо числа, дробь, отрицательное значение, бесконечность и границу ограничения. Включи повтор вызова с теми же входами: результат функции не должен увеличиваться из-за скрытого состояния. Передача таблицы вместо числа не должна изменять эту таблицу. Фактический локальный тест проверяет именно эти утверждения; он не доказывает производственную устойчивость сервера.
| Локальная проверка | Ожидаемый результат |
|---|---|
| Нет записи; добавить 5 | 5 |
| Текущее 100; добавить 5 | 105 |
| Текущее 105; добавить 5 | 110 |
| Строка вместо числа | nil: отмена |
| 999995; добавить 5 | 1000000 |
| 999996; добавить 5 | nil: отмена |
Повтор callback и повтор операции не одно и то же #
Повтор callback внутри одной попытки согласует предложение с изменившимся состоянием. Второй самостоятельный запрос «добавить пять» — другое действие: он может добавить ещё пять даже при использовании UpdateAsync. Поэтому этот метод нельзя выдавать за готовую защиту от двойной награды, повторного заказа или повторной обработки покупки.
Для такой бизнес-задачи нужна отдельно определённая идентичность операции и правило, как проверить её уже выполненное состояние вместе с изменением нужных данных. Здесь мы этого механизма не реализуем. Учебный ключ хранит только число, поэтому в нём нет истории идентификаторов. Когда другой чат будет проектировать экономику игры, передай ему это ограничение вместе с кодом, чтобы демонстрацию не приняли за готовый кошелёк.
Ошибка ответа оставляет отдельный вопрос #
При ошибке сетевого вызова нельзя автоматически заключать, что backend ничего не записал. Документация Roblox отдельно описывает записи с неизвестным исходом: сервер может не получить успешный ответ после фактической записи. Безусловный новый запрос «прибавить пять» тогда способен повторить самостоятельную операцию. Это отличается от внутреннего повторного расчёта callback.
Не добавляй бесконечный цикл повторов в пример. Для настоящей системы сначала определи обработку неизвестного исхода и порядок операций одного ключа. Затем выбирай ограниченные повторы временных ошибок и задержки по подходящим правилам. Наш локальный тест не вызывает сетевой сбой Roblox и не подтверждает решение этой задачи. Такой отказ остаётся отдельным пунктом будущего проектирования.
Одного метода недостаточно для полного профиля #
Полный профиль может содержать несколько связанных полей, данные сессии и метаданные. Пример с одним числом не показывает, как сохранять их при изменении записи. Он также не назначает владельца сессии и не предотвращает ситуацию, когда старый сервер пытается сохранить свой снимок после перехода игрока на новый. Подмена таких задач словом UpdateAsync скрывает оставшуюся работу.
Не используй учебный счётчик для Robux-покупок или настоящих игровых денег. При переносе на production нужны отдельная схема данных, проверки совместимости, обработка отказов и требования к выдаче наград. Начни с официальных рекомендаций по player data и purchasing, а затем проверяй конкретный проект. Мы предлагаем узкий эксперимент, который помогает понять преобразование, а не библиотеку управления профилями.
Что сохранить для следующего разработчика #
Передай рисунок плохого и хорошего порядка, название изолированного ключа, правила числа и список пройденных локальных проверок. Подпиши отдельно: «реальные серверы и сетевые конфликты не тестировались». Добавь нерешённые задачи: неизвестный исход записи, повтор бизнес-операции, владение сессией и сохранение остальных полей профиля.
Когда будешь сравнивать варианты, проси разработчика объяснить, из какого значения он рассчитывает новую запись и какие действия происходят вне callback. Правильный ответ должен быть проверяемым на конкретной последовательности. Если итог выглядит верно только при отсутствии второго писателя, задача конфликта ещё не решена. Сохрани этот гайд вместе с первым руководством по сохранению, чтобы различать начальный доступ к хранилищу и дальнейшее согласование изменений.
Первоисточники
Roblox Creator Hub — Data storesRoblox Creator Hub — GlobalDataStore
Roblox Creator Hub — Data store best practices