毫秒和秒混在一起,先用位数判单位再换算

一、这类缺陷不抛异常,它给你一个合法的错值
一次接口校验失败,日志里找不到异常栈,最后定位到很朴素的一条:页面用 Date.now() 取到的毫秒值,被服务端当成秒来解析。这类问题的麻烦不在于解析不了,而在于它解析得格外顺利。数字只要够长,解析器不会拒绝,只是把它解释成另一个位置:一个本该表示几秒之前的值,被解释成很远未来的某个时刻,格式化出来仍然是一个合法、可读、完全错误的时刻。
所以我们内部对这类问题的定性一直是”取值口径不一致”,而不是安全漏洞。这个定性很重要:把它当漏洞,团队会去做输入过滤和攻击面分析;把它当口径问题,团队才会去做单位约定、归一函数和契约测试,后者才真正止血。
跨端时间数字的来源习惯分得很散,这才是错误必然发生的原因。
| 取值来源 | 常见单位 | 我们的处置 |
|---|---|---|
| 浏览器与客户端运行时 | 毫秒 | 入站先归一到秒再进业务 |
| 后端标准库与系统调用 | 秒,个别接口给微秒 | 单位写进字段名与文档 |
| 日志与埋点管道 | 毫秒为主,偶尔微秒 | 原始整数照存,另建归一列 |
| 数据库时间列 | 与时区配置绑定 | 读出后统一转成秒再比 |
| 第三方回调 | 未知,必须靠契约固定 | 强制归一并做越界标记 |
三类单位里最容易漏的是微秒。它的位数超出十到十三这个区间,本来会被第一步的字符集检查挡掉;但如果某处把单位判断写成”大于十位就算毫秒”,超长的值会被除错一次数量级,得到一个仍然错、但错得不明显的结果。归一逻辑要按”命中已知位数才换算”来写,而不是按”越长越高级”去猜。猜出来的单位在测试里永远通过,在线上永远解释不清。
二、归一函数是四步,跳过任何一步都会留坑
第一步限定字符集。先确认这个字段是纯数字,且位数落在十到十三之间。写法上用一条只允许数字、长度区间明确的正则做前置门,任何带分隔符、带时区标记、含字母的值在这一步就被拒掉,不进入任何猜测逻辑。这一步的价值是把”格式问题”和”单位问题”分开,后面排查时不会两件事搅在一起。
第二步按位数判单位。十位当秒,十三位当毫秒,十一位和十二位是可疑值:它不属于任何一种常见习惯,又短得不可能是毫秒。我们的做法是单独标记并拒绝,不补零也不截断去凑一个合法长度。
第三步是换算,并且必须在这一行把取整方向写死。毫秒到秒是一次除法,除不尽时要么截断要么四舍五入,这个选择会在后面反复咬人。
第四步是区间校验,返回错误或带标记通过。两个边界要写成显式常量:下界取 946656000,上界取 2147483647。前者的含义是”任何还在运行的系统都不可能早于这个位置”,后者是有符号三十一位整数的上限,也就是换算再往前走一步就溢出的那个整数。这两个常量必须以裸整数留在代码里,旁边写一行注释说明它的用途。这里有条实践上的提醒:不要在注释里把它翻译成日历写法。一旦注释里出现日历日期,就会有人开始按日历去”核对”边界,字段规范从整数退化成字符串讨论,第二类错误随之进来。
归一函数最后还有一处构造细节值得单独抄下来:如果需要把归一后的整数再变成时间对象,用带 @ 前缀的方式构造。直接把纯数字交给通用的时间解析入口,它会走字符串解析那条分支,把一串数字按日期字面量去读,最后成功返回一个对象,错得看不出原因。关键比对统一用取时间戳的方法做整数比较,不做字符串转时间,理由是字符串比较会把单位差异与时区差异一起吞掉。
三、取整方向和上界,是最常被省掉的两个细节
截断还是四舍五入,在绝大多数请求上看不出差别,差别只在一秒的边界上出现。一个有效期写成”生成后三十秒内可用”的令牌,如果签发侧四舍五入、校验侧截断,同一个毫秒值在两处会算出相差一秒的秒值,边界上那一次请求就被判成过期。这不是概率极小的事:有效期越短、请求越密集,撞上的次数越多。
处置只有两条。一是全链路固定一种取整方向,并把它写进函数注释与用例断言;二是把边界判定写成严格小于而不是小于等于,避免一秒误差正好压在判定线上。最坏的组合是各处各用平台默认值,多数语言的整除是截断,多数格式化函数是四舍五入,两者混在一条链路里就是稳定复现不了的小概率故障。
上界为什么必须有,答案在业务侧:未来时间戳常被用于绕过有效期与限次逻辑。只设下界的区间校验等于只关了一半的门,一个足够大的数字照样通过,还顺带把”什么时候过期”推到不可达的位置。上界同时挡住了单位错最典型的表现形态:把毫秒当秒解释时结果一定远超上界,于是一个越界判定就把单位缺陷变成可观测的拒绝,而不是数据库里一行看不出来的脏数据。
第二种典型误用是把带时区标记的字符串交给不认时区的解析器。它的特征是同一份数据在两台机器上差整数小时,且差值随部署环境的时区设置变化。检测不必人肉推理:同一份输入在两端各跑一遍,断言归一后的整数值是否相同。这个断言一次写好,之后所有时间字段都受益。
四、时间字段的契约要写五行,断言要断整数
对接阶段把每个时间字段写成五行,缺一项就视为约定未完成:单位(秒、毫秒还是微秒)、时区(绝对时间还是本地时间,带不带偏移)、格式(整数、定长字符串还是带标记的字符串)、样例(一条真实值,而不是”示例值”三个字)、越界如何处理(拒绝、截断还是标记放行)。
第三项与第五项最容易被跳过,因为双方都觉得”格式不就是那样”。样例的意义正在于此:一行真实的值能立刻暴露单位与格式上的分歧,比任何描述都快。越界处理必须写清,否则一端返回错误码,另一端把错误当成”值为空”继续跑,故障会推迟到业务层才显形。
字段名里带单位这件事,比写进文档可靠得多:文档会在第三次改版时失配,字段名跟着代码走,改的时候必然被看到。契约测试的断言要落在归一后的整数上,不要断言格式化字符串。字符串断言会把时区、语言环境、显示精度三个变量绑进用例,任何一台机器的环境差异都能让它失败,而真正的单位错误反倒测不出来。正确写法是同一份输入在客户端与服务端各跑一遍归一,断言两边得到同一个整数;再补一条断言,越界输入返回的是拒绝而不是某个值。
五、五条边界用例进回归,线上排查按三步走
单位类问题的回归集不需要很大,但五条必须齐全,并且要作为时间字段的公共夹具被各接口反复引用。
| 用例 | 输入 | 期望结果 |
|---|---|---|
| 十位纯数字 | 区间内的合理秒值 | 判为秒,原值返回 |
| 十三位纯数字 | 与上一行同一时刻的毫秒值 | 归一后与上一行同值 |
| 非数字 | 空串、含字母、含分隔符 | 拒绝,错误可与越界区分 |
| 负数 | 任意负值 | 拒绝,不进入换算 |
| 过界 | 恰为上界的值与略大于上界的值 | 前者通过,后者拒绝 |
第二条是核心:它把”两种单位指向同一时刻”这件事固化成断言,之后任何人改动取整方向都会立刻看到失败。错误码要区分开,格式非法与区间越界必须是两个码,否则线上看到拒绝量上升时无从判断是有人在灌脏数据还是某个调用方私自改了单位。
线上怀疑单位错,排查按三步走。第一步看位数分布:把该字段的整数长度做直方图,秒与毫秒会自然分成两堆,双峰就是最直接的痕迹。第二步按调用方分组:只有一个来源的位数异常,说明某一方自己改了写法;全部来源同时变化,说明接口响应侧改了。第三步查历史入库:确认库里是否已按错单位存过。若存在,按长度批量转换一次做完,记录转换批次与影响行数,别让两种单位在一张表里长期共存。共存期间,这张表上所有的统计与有效期判定都不可信,而这正是最难向甲方解释的部分。
六、判定权必须留在服务端,客户端时间只是线索
有效期、限次、幂等窗口这三类判定,必须以服务端时间为唯一参照。客户端时间只保留两个用途:按用户所在时区做展示,以及在排查事故时作为先后顺序的线索。它不能进判定,原因不是不信任用户,而是设备时钟不可控:用户手改系统时间、设备离线后自动校正、跨时区旅行,三种情况都会让客户端时间跳到过去或未来。
服务端侧还有一层区分要做。跨系统的先后与有效期用带时区定义的墙上时间,同一进程内的耗时与重试间隔用单调时钟。两者混用会出现时间倒流型故障:一次校时把墙上时间往前挪,刚建立的会话立刻过期,或者本该到期的窗口永不到期。落库时把墙上时间与单调时钟各自独立记录,比对归属按墙上时间,间隔判定按单调时钟,两条不要互相顶替。
洹拓经手的几个带客户端上报时间字段的系统,收口动作基本一致:一个归一函数、一份五行契约、五条公共用例、一个服务端参照。四样东西加起来不超过两天的工作量,却能挡掉这类问题的几乎全部复发。剩下的功夫在习惯上:新接口评审时如果有人问得出”这个时间字段是几位的”,这条链路就已经是安全的了。