事故:同一份配置,三个开服时间
《潮汐方舟》的赛季活动在配置里写的是「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 时区标识并显式配置重置小时。
- 把时间不准与时区不对记成同一个错误。两者修复动作不同,日志字段必须分开。
- 边界:本节不定义服务端结算规则与反作弊判定。客户端只提供锚点、跳变信号与展示,最终判定属于服务端。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。