《移动游戏产品的架构·客户端与移动运行时》11.2 时区、服务器时间与本地时间

《潮汐方舟》赛季结算的事故复盘:活动边界只认服务器 epoch,客户端时区只负责显示,每日重置按账号区域而不是设备时区。给出 RTT 半程同步、单调时钟锚点、设备回拨检测、DST 日界线、窗口判定与时间回拨观测的可运行模型以及客服排查口径。

事故:同一份配置,三个开服时间

《潮汐方舟》的赛季活动在配置里写的是「11 月 2 日 00:00 开启」。客户端直接把这个字符串交给 new Date("2026-11-02T00:00:00"),没有带时区。结果 UTC+8 的玩家在本地零点看到活动,UTC-8 的玩家要再等八小时;同一张排行榜上,先开的服务器已经打了两轮,后开的还没开始。运营只能临时延长活动,补偿邮件与公告又引出了第二天的客服高峰。

另一类问题来自设备时钟。有玩家把系统时间往前调一天,反复领取每日奖励;还有玩家跨时区飞行后,客户端把「今天」算成了前一天,连续签到断档。根因相同:客户端把设备墙钟当成了业务时间的权威来源。 服务器时间的职责不是「告诉玩家现在几点」,而是「决定活动是否开始、奖励是否可领、结算属于哪一天」。

三种时间与信任边界

客户端里同时存在三种时间,混用就会产生上面的事故。它们的信任级别不同,用途也必须分开。

时间来源典型接口可信度允许用途
服务器 epoch登录与心跳下发 serverEpochMs权威活动开关、结算、奖励判定
规则时区配置中的 IANA 时区,如 UTC、Asia/Shanghai权威日界线、赛季归属、运营口径
设备墙钟Date.now()不可信仅本地提示,不参与判定
单调时钟performance.now()进程内可信计算经过时长、锚定服务器时间

设备墙钟可以被用户修改,也可以在跨时区、夏令时切换、系统对时后跳变;它适合显示「本地现在几点」,不适合回答「活动是否结束」。单调时钟不会回拨,但它的原点是进程启动,不能直接映射到墙上时间。正确做法是用一次握手把两者锚定:服务器 epoch 决定绝对时间,单调时钟负责在两次握手之间平滑推进。

服务器时间同步:RTT 半程与单调锚点

握手返回 serverEpochMs、客户端墙钟、客户端单调时钟与 RTT。服务器时间戳在请求处理时生成,客户端收到时已经过了大约半个 RTT,因此估算服务器当前时间为 serverEpochMs + rttMs / 2。锚点保存的是「服务器时间减去客户端单调时钟」的偏移,之后每次读取都用当前单调时钟加偏移,而不是重新读墙钟。

这样做的直接效果是:玩家改系统时间,服务器时间不变;系统自动对时,服务器时间也不跳。握手本身有误差,误差上界是半个 RTT;RTT 过大时状态标为 wide-uncertainty,业务应避免用它做秒级判定。重连、切后台回来、网络切换后重新握手,并记录 resyncCount,方便排查「为什么这个玩家看到的时间在跳」。

设备墙钟偏移仍要检测:用「当前墙钟减去锚点墙钟与单调经过时间之和」,得到 deviceJumpMs。超过阈值就标记 device-clock-jump,用于客服解释与风控信号,但不能反过来修改服务器时间。客户端只负责锚点与观测,最终判定仍属于服务端。

活动开启与结算的时区口径

同一个「一天」在不同玩法里可能有不同定义,必须先写清楚口径,再写代码。

场景时区口径判定方式展示方式
全球竞技赛季固定 UTC服务器 epoch 比较玩家本地时区
每日任务重置账号注册区域,如 Asia/Shanghai规则时区 + 重置小时玩家本地时区并注明规则
区域活动活动配置的规则时区服务器 epoch 比较玩家本地时区
客服与运营后台规则时区与 UTC 双显示只读双时区

竞技赛季用固定 UTC,是为了让所有玩家在同一瞬间开始和结束;如果按各自本地零点开赛,不同时区的玩家可玩时长不同,排行榜就不公平。每日重置则通常按账号归属区域计算,而不是设备时区:玩家去旅行、改系统时区都不应该多领一次奖励。展示层可以跟随设备时区,但必须让玩家看见「按哪个时区重置」,否则跨时区玩家会认为签到丢了。日期与数字的本地化格式可以参考日期、数字、货币与 RTL 。

配置里只存 epoch 毫秒与规则时区标识,不存「11 月 2 日 00:00」这种无时区字符串。运营后台可以用本地时间录入,但在写入配置前必须转换成 epoch,并回显转换后的 UTC 与规则时区时间供二次确认。

DST 与日界线:不要给本地时间加 86400000

夏令时切换当天可能只有 23 小时或 25 小时。直接给本地时间加 86_400_000 毫秒,会在切换日算出错误的「明天」;用本地时间构造 Date 再取 epoch,还可能遇到不存在的本地时刻(春季跳过的 02:00–03:00)或重复时刻(秋季回拨的一小时)。

模型的策略是:所有业务判定用 epoch;需要「日」时,用 Intl.DateTimeFormat 取出指定时区的年、月、日、时,再在 UTC 民用日期上做加减。businessDayKey 在小时小于重置小时时把民用日期减一天,这样 05:00 重置不会因为 DST 变成 04:00 或 06:00。需要检测切换时,按小时采样当天偏移,记录偏移变化与对应的本地小时。

测试用 America/New_York 验证两次切换:2026-03-08 从 -300 分钟跳到 -240 分钟,2026-11-01 从 -240 分钟回到 -300 分钟。上海没有夏令时,但同一套代码要能同时处理两者,不能把「+08:00」写死。

时间回拨与作弊边界

设备时间回拨不是客户端能单独解决的问题。客户端的职责是:不让回拨影响显示与本地缓存的有效期判断;把 deviceJumpMs、resyncCount 与关键操作时间上报;在本地时间明显不可信时,禁用依赖本地时间的入口,改为向服务器查询。

服务器负责最终判定:领奖、结算、体力恢复、活动结束都以服务器时间为准,并对异常时间线做校验。客户端不做「我觉得还能领」的乐观放行,也不把本地时间写进奖励凭证。反作弊的完整规则属于服务端与风控,客户端只提供可观测信号;存档侧的时间戳与回滚校验在 10.3 防篡改与回滚 已讨论。

还要区分「时间不准」与「时区不对」。前者是墙钟偏移,后者是 IANA 时区选择错误;两者的日志字段、客服话术与修复动作都不同。把二者都记成 time_error,排查时只能靠猜。

可运行模型:timezone.ts

下面的模型把 RTT 半程同步、单调锚点、设备跳变检测、时区取件、日界线、DST 切换、活动窗口与本地化展示放在同一个文件里。时间源通过 TimeSource 注入,测试不依赖真实系统时钟。

// file: src/i18n/timezone.ts
// tier: L1
// verify: node --test test/i18n/timezone.test.ts

export interface TimeSource {
  wallMs(): number;
  monotonicMs(): number;
}

export class SystemTimeSource implements TimeSource {
  wallMs(): number { return Date.now(); }
  monotonicMs(): number { return globalThis.performance.now(); }
}

export interface Handshake {
  readonly serverEpochMs: number; readonly clientWallMs: number;
  readonly clientMonoMs: number; readonly rttMs: number;
}

export type ClockState = "ok" | "device-clock-jump" | "wide-uncertainty";

export interface ClockStats {
  readonly state: ClockState; readonly serverNowMs: number; readonly deviceWallMs: number;
  readonly deviceJumpMs: number; readonly uncertaintyMs: number; readonly resyncCount: number;
}

export interface ClockOptions { readonly jumpThresholdMs?: number; readonly maxUncertaintyMs?: number }

export class ServerClock {
  readonly #source: TimeSource;
  readonly #jumpThresholdMs: number;
  readonly #maxUncertaintyMs: number;
  #offsetMs = 0;
  #uncertaintyMs = 0;
  #monoAtAnchorMs = 0;
  #wallAtAnchorMs = 0;
  #resyncCount = 0;
  #synced = false;

  constructor(source: TimeSource, options: ClockOptions = {}) {
    this.#source = source;
    this.#jumpThresholdMs = options.jumpThresholdMs ?? 2_000;
    this.#maxUncertaintyMs = options.maxUncertaintyMs ?? 5_000;
  }

  resync(handshake: Handshake): void {
    if (!Number.isFinite(handshake.serverEpochMs) || !Number.isFinite(handshake.rttMs) || handshake.rttMs < 0) {
      throw new RangeError(`invalid handshake: ${JSON.stringify(handshake)}`);
    }
    this.#offsetMs = handshake.serverEpochMs + handshake.rttMs / 2 - handshake.clientMonoMs;
    this.#uncertaintyMs = handshake.rttMs / 2;
    this.#monoAtAnchorMs = handshake.clientMonoMs;
    this.#wallAtAnchorMs = handshake.clientWallMs;
    this.#resyncCount += 1;
    this.#synced = true;
  }

  nowMs(): number {
    if (!this.#synced) throw new Error("server clock is not synchronized");
    return this.#source.monotonicMs() + this.#offsetMs;
  }

  deviceJumpMs(): number {
    if (!this.#synced) return 0;
    const elapsed = this.#source.monotonicMs() - this.#monoAtAnchorMs;
    return this.#source.wallMs() - (this.#wallAtAnchorMs + elapsed);
  }

  stats(): ClockStats {
    const deviceJumpMs = this.deviceJumpMs();
    const state: ClockState = Math.abs(deviceJumpMs) > this.#jumpThresholdMs
      ? "device-clock-jump"
      : this.#uncertaintyMs > this.#maxUncertaintyMs ? "wide-uncertainty" : "ok";
    return {
      state, serverNowMs: this.#synced ? this.nowMs() : this.#source.wallMs(),
      deviceWallMs: this.#source.wallMs(), deviceJumpMs,
      uncertaintyMs: this.#uncertaintyMs, resyncCount: this.#resyncCount,
    };
  }
}

export interface ZonedParts {
  readonly year: number; readonly month: number; readonly day: number;
  readonly hour: number; readonly minute: number; readonly second: number;
}

const FORMATTERS = new Map<string, Intl.DateTimeFormat>();

function formatterFor(timeZone: string): Intl.DateTimeFormat {
  let formatter = FORMATTERS.get(timeZone);
  if (formatter === undefined) {
    formatter = new Intl.DateTimeFormat("en-US", {
      timeZone, year: "numeric", month: "2-digit", day: "2-digit",
      hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23",
    });
    FORMATTERS.set(timeZone, formatter);
  }
  return formatter;
}

export function zonedParts(epochMs: number, timeZone: string): ZonedParts {
  if (!Number.isFinite(epochMs)) throw new RangeError(`invalid epoch: ${epochMs}`);
  const fields: Record<string, number> = {};
  for (const part of formatterFor(timeZone).formatToParts(new Date(epochMs))) {
    if (part.type !== "literal") fields[part.type] = Number(part.value);
  }
  return {
    year: fields.year, month: fields.month, day: fields.day,
    hour: fields.hour, minute: fields.minute, second: fields.second,
  };
}

export function zoneOffsetMinutes(epochMs: number, timeZone: string): number {
  const parts = zonedParts(epochMs, timeZone);
  const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
  return Math.round((asUtc - Math.floor(epochMs / 1_000) * 1_000) / 60_000);
}

function pad(value: number): string { return String(value).padStart(2, "0"); }

export function dayKey(epochMs: number, timeZone: string): string {
  const parts = zonedParts(epochMs, timeZone);
  return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`;
}

export function businessDayKey(epochMs: number, timeZone: string, resetHour = 0): string {
  if (!Number.isInteger(resetHour) || resetHour < 0 || resetHour > 23) {
    throw new RangeError(`resetHour must be an integer within [0, 23]: ${resetHour}`);
  }
  const parts = zonedParts(epochMs, timeZone);
  let civilMs = Date.UTC(parts.year, parts.month - 1, parts.day);
  if (parts.hour < resetHour) civilMs -= 86_400_000;
  const civil = new Date(civilMs);
  return `${civil.getUTCFullYear()}-${pad(civil.getUTCMonth() + 1)}-${pad(civil.getUTCDate())}`;
}

export function civilDayStartEpoch(epochMs: number, timeZone: string): number {
  const parts = zonedParts(epochMs, timeZone);
  const guess = Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0);
  let candidate = guess - zoneOffsetMinutes(guess, timeZone) * 60_000;
  candidate = guess - zoneOffsetMinutes(candidate, timeZone) * 60_000;
  return candidate;
}

export interface OffsetChange {
  readonly atMs: number; readonly localHour: number;
  readonly fromOffsetMinutes: number; readonly toOffsetMinutes: number;
}

export function transitionsOnDay(epochMs: number, timeZone: string): readonly OffsetChange[] {
  const start = civilDayStartEpoch(epochMs, timeZone);
  const changes: OffsetChange[] = [];
  let previous = zoneOffsetMinutes(start, timeZone);
  for (let hour = 1; hour <= 24; hour += 1) {
    const atMs = start + hour * 3_600_000;
    const current = zoneOffsetMinutes(atMs, timeZone);
    if (current !== previous) {
      changes.push({ atMs, localHour: zonedParts(atMs, timeZone).hour, fromOffsetMinutes: previous, toOffsetMinutes: current });
      previous = current;
    }
  }
  return changes;
}

export type WindowStatus = "before" | "open" | "after";

export interface ActivityWindow {
  readonly id: string; readonly startMs: number; readonly endMs: number;
  readonly ruleTimeZone: string; readonly resetHour: number;
}

export interface WindowVerdict {
  readonly id: string; readonly status: WindowStatus; readonly serverNowMs: number;
  readonly remainingMs: number; readonly progress: number; readonly ruleDayKey: string;
  readonly displayStart: string; readonly displayEnd: string;
}

const DISPLAY_FORMATTERS = new Map<string, Intl.DateTimeFormat>();

export function formatInZone(epochMs: number, timeZone: string, locale = "en-US"): string {
  const key = `${locale}|${timeZone}`;
  let formatter = DISPLAY_FORMATTERS.get(key);
  if (formatter === undefined) {
    formatter = new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium", timeStyle: "short" });
    DISPLAY_FORMATTERS.set(key, formatter);
  }
  return formatter.format(new Date(epochMs));
}

export function evaluateWindow(
  window: ActivityWindow, serverNowMs: number, playerTimeZone: string, locale = "en-US",
): WindowVerdict {
  if (window.endMs <= window.startMs) throw new RangeError(`window ${window.id} has a non-positive duration`);
  const status: WindowStatus = serverNowMs < window.startMs ? "before" : serverNowMs >= window.endMs ? "after" : "open";
  const remainingMs = status === "before" ? window.startMs - serverNowMs : status === "open" ? window.endMs - serverNowMs : 0;
  return {
    id: window.id, status, serverNowMs, remainingMs,
    progress: Math.min(1, Math.max(0, (serverNowMs - window.startMs) / (window.endMs - window.startMs))),
    ruleDayKey: businessDayKey(serverNowMs, window.ruleTimeZone, window.resetHour),
    displayStart: formatInZone(window.startMs, playerTimeZone, locale),
    displayEnd: formatInZone(window.endMs, playerTimeZone, locale),
  };
}

配套测试 test/i18n/timezone.test.ts 覆盖 11 条路径:同步偏移、墙钟跳变不影响服务器时间、宽误差、未同步拒绝、时区取件、标准时与夏令时偏移、跨日界线、05:00 重置、春秋两次 DST 切换、窗口三态与同刻异区展示。运行命令是 node --test test/i18n/timezone.test.ts。

模型验证的是时间口径与算术,不是网络实测。真实 RTT 会抖动,握手可能失败,系统时区数据库版本也可能不同;这些都属于运行期观测与兼容性矩阵,不能由单元测试替代。

双案例:方舟的赛季与消消的活动

方舟是强联网、赛季结算与异步 PvP,时间口径必须统一到 UTC,客户端只做展示;任何按设备时区开赛的实现都会直接破坏排行榜公平。

消消是弱联网,每日活动与云存档更依赖账号区域;它的重点是重置口径稳定、离线时本地时间不可信,而不是秒级同步。

与相邻章节的关系

11.1 处理文本与字体,本节处理时间,11.3 处理区域与内容分级。时间同步的客户端侧只到「锚点与展示」,服务端权威与结算规则属于服务端架构;存档时间戳与回滚判定在 10.x 已讨论,本节不重复。

小结

  • 活动开关、结算与奖励判定只认服务器 epoch,设备墙钟不可信。
  • 同步用 RTT 半程估算,锚定单调时钟;墙钟跳变只记录,不修改服务器时间。
  • 竞技赛季统一 UTC,每日重置按账号区域,展示才跟随玩家时区。
  • 日界线用指定时区的年月日计算,不给本地时间直接加 86400000。
  • DST 切换日可能是 23 或 25 小时,必须用 IANA 时区与偏移采样验证。

阅读导航:上一节:11.1 文本、字体与排版 · 下一节:11.3 区域化配置与内容分级 。

常见错误与边界

  • 用无时区字符串配置活动时间。「11 月 2 日 00:00」在不同设备上是不同瞬间;配置只存 epoch 与规则时区。
  • 用设备墙钟判断活动是否结束。玩家改时间就能提前或延后;判定必须在服务器或基于服务器时间。
  • 每次读时间都重新取 Date.now()。系统对时会让时间跳变;两次握手之间应使用单调锚点。
  • 把本地时间加 86400000 当成明天。DST 切换日会错一小时;用指定时区的年月日做民用日期运算。
  • 把重置时区写死成 UTC+8。跨区域运营会失真;使用 IANA 时区标识并显式配置重置小时。
  • 把时间不准与时区不对记成同一个错误。两者修复动作不同,日志字段必须分开。
  • 边界:本节不定义服务端结算规则与反作弊判定。客户端只提供锚点、跳变信号与展示,最终判定属于服务端。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查