
凌晨两点一刻告警群被一条消息炸醒了核心订单服务慢查询SQL 里夹着一个极其扎眼的数字——9999999999999。我盯着这个 13 位数字看了几秒第一反应不是参数传错了而是这东西终于还是来了。干过后端的人都知道9999999999999这种数字从来不是用户手敲的它通常是某个系统在实在不知道该填什么的时候硬塞进去的兜底值。而这个兜底值一旦绕过校验进到数据库查询里轻则慢查询重则全表扫描打满 CPU。这篇文章就从这个真实的告警说起我把自己排查这串 9 的完整过程、背后涉及的边界值原理、以及后来沉淀下来的防御手段都整理出来。适合后端开发、SRE、以及所有被极值参数坑过的同学看完你至少能学会一件事拿到一串奇怪的数字怎么快速判断它是什么以及怎么让你的系统不再被这种数字击穿。1. 告警现场一串9把慢查询问题带了出来1.1 先看现象再猜原因当时的告警信息比平时要详细我先把关键内容摘出来服务名称订单查询服务错误类型慢查询耗时超过 5 秒SQL 片段WHERE create_time 9999999999999表数据量单表三千万行DBA 反馈该查询直接导致一个 CPU 核长时间打满这个 SQL 看起来只查了一个条件但恰恰是这个条件让 MySQL 的优化器认为没有可用的索引范围最终选择全表扫描。三千万行的表从头到尾扫一遍时间肯定慢。可问题在于谁会传一个9999999999999当时间参数第一步不是改代码而是先确认这个数字是从哪一层进来的。是前端页面是开放接口还是内部服务调用1.2 13位数字的直觉毫秒级时间戳看到 13 位数字有经验的后端会马上想到毫秒时间戳。时间戳的位数是有规律的精度当前时间示例位数说明秒级171000000010 位Unix 时间戳毫秒级171000000000013 位前端 JS 常用微秒级171000000000000016 位极少见性能分析用纳秒级171000000000000000019 位Java System.nanoTime9999999999999正好 13 位显然是在模仿一个毫秒级时间戳。把它除以 1000 转成秒大约是 9,999,999,999.999 秒换算成年份大概是2286 年 11 月 20 日。也就是说这条 SQL 的真实意图是查询创建时间大于公元 2286 年的订单。显然没有任何订单满足这个条件但数据库可不管你查的是什么没有走对索引就是全表扫。1.3 我为什么不觉得这是算错了数这里有一个从业者经验问题真实系统里9999999999999不是普通用户能碰巧填出来的也不是服务器当前时间现在是 2025 年。它更像是一个人为定义的极值常量——在某段代码里被当作无限大来使用。更值得警惕的是13 个 9 的毫秒时间戳并不等于人类常说的9999 年。很多人以为9999999999999就是大后天的意思其实不然。人类可读的9999-12-31 23:59:59转成毫秒是253402300799999比9999999999999大了约 25 倍。这俩差了十万八千里但都经常出现在代码的默认值里。所以这个 13 位的 9 不是时间计算错误它极有可能是某个组件在兜底逻辑里塞进来的伪极值。接下来要做的就是沿着调用链把它揪出来。2. 数据链路反查四个环节过滤出一串9的真实来源2.1 顺着 traceId 一层层剥我们的服务接入了全链路追踪我把这次慢查询的 traceId 调出来梳理出完整的调用链路前端页面 - 网关 - 订单查询服务 - 订单中心服务 - MySQL每个环节都可能往时间参数里塞东西。排查策略是倒着来先从最下游的 SQL 参数往上游推每一步都对比入参和出参看数字在哪一层发生变化。排查结果如下网关层参数未修改原样透传订单查询服务日志里打印的入参是startTime 9999999999999订单中心服务接收到的值也是9999999999999最终 SQL 中直接拼入条件也就是说从后端视角看这个值从第一个服务进来就已经是9999999999999了排除了后端内部组件篡改的可能性。2.2 数据库里到底有没有2286年的数据这里插一个关键操作虽然 SQL 条件传了9999999999999但我还是让 DBA 跑了一条确认 SQLSELECT MAX(create_time) FROM order_table WHERE id 1000000;结果返回2025-06-18 14:22:31表里最新的数据也就是正常时间。这说明9999999999999转成日期2286-11-20后根本不可能匹配到任何数据。但恰恰是这种不可能匹配到任何数据的条件让 InnoDB 无法走索引范围扫描。你可能会问为什么不直接走索引找到空结果因为是范围条件优化器需要评估范围大小当最大值远超出索引的实际范围时部分版本会放弃索引下推选择全表扫描反而拖垮性能。2.3 全局搜索“9999999999999”根因浮出水面最直接的定位方式是在代码库里全局搜索这个字符串。这个操作一定要做而且要连同9999999999999L、9999999999999l、9999999999999一起搜。我在前端仓库里找到了这么一段代码// date-picker 组件配置 const DEFAULT_MAX_DATE 9999999999999; // 时间控件的最大可选时间防止限制未来时间也就是说前端的时间选择控件内部把最大可选时间定义成了9999999999999毫秒时间戳。用户如果没有主动选择时间或者选择了不限组件就会把这个内部极值当作查询参数提交上来。后端对时间字段没有做任何范围校验直接透传进了 SQL。就这样一个前端控件的内部常量一路穿透到了数据库查询条件里。2.4 处理这个案子的完整动作清单根因找到后修复动作其实是常规操作但有几个坑必须同时堵住后端增加时间参数范围校验超出2020-01-01到2099-12-31的请求直接返回参数错误前端修改时间控件的默认提交逻辑用户选不限时不要传这个内部极值而是传空字符串或合法范围API 文档补充时间字段的有效范围并注明后端不受理超出业务范围的时间查询慢查询告警里加一条规则SQL 参数中出现9999999999999时优先触发根因通知这个案子本身不复杂但如果只在后端加校验、前端不改问题只是从慢查询变成前端报错如果只改前端、后端不加校验其他历史版本客户端仍然会打过来。两端必须同时处理才算真正关掉这个口子。3. 一串9还能是哪几种身份占位值、溢出值、缺省值、格式化垃圾在排查这种数字极值时最怕的是只盯着一种可能。9999999999999在不同的上下文里完全可能是完全不同的东西。我根据自己的经验把这串 9 可能出现的情况分成四类供你对照排查。3.1 占位值 / 哨兵值表示永不过期这是最常见的情况。系统里需要表达永远有效时很多研发懒得用可空字段直接塞一个未来的极限作为哨兵值。典型的例子Java 里用Long.MAX_VALUE当作缓存过期时间MySQL 表里把expires_at默认设为9999-12-31 23:59:59Redis 设置ttl -1表示永久但某些封装组件会把它转成一个大数9999999999999在这类代码里经常被当成业务上的永远。它的优点是写起来简单缺点是一旦这个值被当成普通业务参数传递、计算、写入索引就会制造各种匪夷所思的 bug。务必记住在毫秒时间戳的世界里9999999999999对应的是 2286 年而9999-12-31对应的毫秒值是253402300799999。很多人把这俩搞混排查时花了半天找 9999 年结果实际代码写的是另一重含义。表达永远的方式数值/写法转日期毫秒时间戳极值99999999999992286-11-20人类可读极值9999-12-31 23:59:599999-12-31人类可读极值转毫秒2534023007999999999-12-31Java Long 最大值9223372036854775807292278994 年3.2 溢出垃圾值算出来的畸形数第二种情况是计算溢出的产物。比如 32 位有符号秒级时间戳的上限是2147483647对应 2038 年 1 月 19 日一旦超过这个值旧式系统可能溢出成负数也可能被某些库填充成错误标记。9999999999999这类大数也可能是其他数值类型在序列化、反序列化时被强转出来的。比如某个字段用unsigned int存储代码里读出来后硬转成一个 64 位长整型中间如果发生符号扩展就会产生远超预期的大数。识别特征很明确这类数字通常出现在底层库、驱动、硬件接口传上来的数据里而不是业务代码里直接定义。排查时对比一下不同语言环境下的数据转换逻辑基本能对上号。3.3 缺省值SDK 默认填了一大坨9第三种情况是 SDK 或基础组件的默认缺省值。有些第三方 SDK 接收时间参数时提供方为了表示不限制会在代码里硬编码一个极大值作为默认值。调用方只要没显式传时间SDK 就自动带上了它的不限制常量。我见过一个 Java SDK 的代码public static final long DEFAULT_END_TIME 9999999999999L;然后所有未指定endTime的事务都默认查到了 2286 年。表面看没问题但你一旦把这个 SDK 用在报表系统里生成的报表会莫名其妙地不包含最近一天的数据因为时间范围的下限和上限都被处理成了奇怪的大数。识别特征接口文档里的默认值一栏如果写着当前时间或不限但实际请求里出现了一串 9那大概率就是 SDK 的默认实现。3.4 格式化/解析垃圾值字符串变成时间的意外产物第四种比较隐蔽是字符串解析成日期时产生的垃圾值。比如某个前端把时间格式化成字符串9999999999999后端用SimpleDateFormat或DateTimeFormatter去解析如果格式模板写得不严格某些 JDK 版本不会直接报错而是会推导出一个尽量大的合法日期。举例来说// 错误示范 String value 9999999999999; DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyyMMddHHmmss); LocalDateTime.parse(value, formatter); // 大概率抛异常但某些宽松模式下会出现怪异结果在存量代码里这种解析失败后的兜底逻辑如果写得不够严谨异常被吞掉后塞入一个new Date(Long.MAX_VALUE)或者new Date(9999999999999L)就会把垃圾值继续向数据库传递。判断这类情况的方法是看时间字段在程序中的数据类型。如果是字符串转 Date检查解析代码的容错分支如果是数据库驱动直接返回则查看驱动的连接参数。3.5 一张表看清四种身份的区分方法出现场景最可能的身份判断线索请求参数、接口入参前端控件缺省值/占位值抓包 前端代码搜索常量SDK 自动补全的字段缺省值查 SDK 源码或反编译 jar 包底层库/硬件上报数据溢出值/错误标记对比字段类型和转换逻辑日志打印出来的一串数字格式化垃圾值查看 Date 的 toString 或框架序列化配置4. 极值不只在时间戳排序污染、分区分表越界、ID耗尽的连锁事故把时间戳的问题聊清楚之后很多人会以为这只是时间字段特有的坑其实不是。任何接近数据类型上限的数值被塞进业务逻辑都可能引发连锁事故。我在这个项目里踩过的坑不只有时间戳。4.1 排序污染脏数据永远排在第一条先说一个大概率会遇到的场景。运营后台经常有按创建时间倒序取最新一条记录的需求SQL 大概是SELECT * FROM order_table ORDER BY create_time DESC LIMIT 1;正常情况下这个查询毫秒返回因为它走create_time索引的末尾即可。但如果表里有一条脏数据create_time是9999999999999对应的 2286 年那么这条记录永远最新。所有依赖取最新一条或取最近 N 条的逻辑都会被这条数据污染而且极难发现真相。我的建议是排查取最新数据异常时先别急着怀疑排序和索引用一条MAX(create_time)看看表里真实的最大时间如果一眼看出是未来时间那就是脏数据污染。4.2 分表路由越界查不到不是问题写不进才是再深一层是分库分表的场景。如果分表键是时间比如order_table_202501、order_table_202502路由算法根据时间取年月拼表名。当某个请求携带9999999999999作为路由参数时算出来的年月是 228611对应一张根本不存在的表。这种查询通常会直接报错而写入场景更难受新数据被路由到不存在的物理表业务上表现为写入成功但查不到或者数据神秘丢失。定位时可以看分表中间件的路由日志数值异常一目了然。4.3 自增主键和分布式ID逼近上限时间戳之外还有两类极值经常被忽视数据库自增主键和分布式 ID。MySQL 的INT UNSIGNED上限是 4294967295。如果一张表的自增主键已经接近这个值插入新记录就会报主键溢出错误。排查时注意看表的AUTO_INCREMENT值而不是看目前有多少行——因为删过的数据不会让主键回落。分布式 ID 里也容易踩坑。雪花算法由 时间戳 机器ID 序号 组成很多实现里timestamp位域如果异常比如时间回拨后取绝对值或者直接填了9999999999999生成出来的 ID 会超出业务预期范围导致下游解析异常。我自己见过一个案例业务方把无穷大时间直接塞进 ID 生成器得到的 ID 打印出来是一串以 9 开头的大数看起来非常像9223372036854775807排查时差点往 Long 最大值的方向钻死胡同。这里特别说明一下9223372036854775807是Long.MAX_VALUE19 位转成毫秒时间戳对应的日期远超人类可读范围。如果日志里出现这种 19 位大数往往和过期时间默认值或ID 生成兜底有关排查方向要区分清楚。4.4 极值事故的共同特征与五步排查法这类问题的共同特征是问题不在数据本身而在于系统对边界值的假设。你假设create_time不会超过当前时间假设 ID 不会超过INT上限假设前端不会传一个 13 位的 9——但只要有一个环节没有校验这个假设就会被打破。我总结了一套处理极值事故的五步法靠它处理了不止一次线上事故复现并记录原始数值不要先改代码先保留现场确认这个数值来自哪一层前端、后端、SDK、数据存储用链路追踪或日志定位判断这个数值的类型身份用第三节的分类表逐一比对明确影响面是查询慢、排序错还是写入失败、路由越界修复两端源头和出口再加一条监控告警防止复发5. 防御体系把“极限值”挡在业务之外经验告诉我光靠下次注意完全挡不住极值问题。必须建立一套防御体系。这套体系我分成四个层次入参校验、业务设计、出入口设防、监控告警。5.1 入参校验时间字段必须有业务范围所有外部输入的时间参数第一件事不是转换格式而是校验范围。注意这个范围不是技术范围而是业务范围。技术范围是0 ~ Long.MAX_VALUE业务范围则是本系统只接受 2020-01-01 到 2099-12-31 的查询时间。我在项目里通常用一段简单的代码// 业务时间范围校验示例 public static boolean isValidBusinessTime(Long ts) { if (ts null) { return false; } // 明确拒绝非法输入 if (ts 0) { return false; } long lowerBound LocalDateTime.of(2020, 1, 1, 0, 0, 0) .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); long upperBound LocalDateTime.of(2099, 12, 31, 23, 59, 59) .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); return ts lowerBound ts upperBound; }注意两点。第一下限不要设成 0因为 1970 年附近的时间戳同样是脏数据只是某些系统恰好能处理。第二业务范围不是拍脑袋定的需要和产品确认查询窗口最长多久比如订单系统通常只需要查最近三年那么范围就设为当前时间往前三年、往后一天。5.2 业务设计不要把永不过期写成字面极值很多极值问题的根源是设计上用了字面极值表达永久。我的建议是彻底改掉这个习惯Redis 缓存过期用Duration对象动态设置过期时长不存在的 key 就是永久不需要塞一个大时间戳数据库字段默认值优先用NULL表示无期限而不是填入9999-12-31 23:59:59会话不过期用独立的标志位字段而不是把expires_at设成未来极值前端控件当用户选择不限时提交空值或省略参数而不是把内部的最大时间传出来核心原则是永久应该是一种状态而不是一个时间点。只要把它建模成时间点就必然遇到数值极值问题只是早晚而已。5.3 入口、出口、存储三层设防即便业务设计改了存量代码和历史版本仍然可能携带极值。所以要在三个层面设防防线位置做法入口API 网关 / Controller / RPC 入口对时间字段做统一参数校验拒绝超范围值出口ORM 层 / SQL 组装层增加拦截器检查 SQL 参数发现极值打日志并阻断存储数据库触发器 / 约束一般不推荐过度使用但关键审计表可以加约束入口层拦截效果最好性能消耗最小但要求所有流量都经过统一入口。出口层的作用是兜底——防止某些历史调用绕过入口直接触达数据库。存储层的触发器开销较大我只建议在核心审计表、账务表上使用普通业务表不考虑。5.4 监控告警给“极值数字”留一个常驻位置最后一条非常实用但很多人想不到把极值数字本身加入监控关键词。我在告警系统里配置了带有这些关键词的规则一旦日志中出现立刻触发告警999999999999999999999999999-12-3192233720368547758074294967295为什么要这样配置因为这类极值数字出现的时候往往先打在日志里还没有造成业务影响。如果能在这个阶段收到告警就能在用户感受到异常之前处理掉而不是等慢查询拖垮数据库后再补救。这条规则实施之后我们半年内拦截了至少五次潜在问题其中三次是前端控件、一次是 SDK 默认值、一次是测试环境数据混入。6. 边界值测试别让“最大”成为埋雷桶前面讲的都是线上事故处理但真正高水平的团队应该把极值问题挡在测试阶段。这块的核心就是边界值测试。6.1 边界值法七个关键测试点当一个接口接受时间参数时我建议至少测七个点最小值减一、最小值、最小值加一、正常值、最大值减一、最大值、最大值加一。虽然听起来简单但很多团队测试用例里只有正常值和空值。测试点时间戳值预期行为下界外-1参数错误下界2020-01-01 00:00:00正常查询下界内2020-01-02 00:00:00正常查询正常值当前时间正常查询上界内2099-12-30 00:00:00正常查询上界2099-12-31 23:59:59正常查询上界外253402300799999参数错误极值9999999999999参数错误空值null按业务规则处理这套矩阵要写进自动化测试每次发版回归都跑一遍。只要边界值不出现意外极值问题基本被提前消灭。6.2 实践中容易漏掉的边界问题即便做了边界值测试还是有几个具体情况容易漏前端上限和后端上限不一致。比如前端时间控件最大可选 2030 年后端上限 2099 年中间存在一个前端选不到、后端能传的区间毫秒与秒混用。同一个字段有的接口传秒级时间戳有的传毫秒级时间戳边界值差了 1000 倍时区默认值。前端 JS 默认用本地时区后端统一用 UTC边界值附近的数据可能差 8 小时闰秒。虽然日常系统很少受影响但高精度时间系统里边界值的测试需要考虑闰秒对排序的影响6.3 在代码里固化边界值定义最后分享一个我自己的小习惯在领域模型层定义显式的业务时间边界常量所有校验逻辑都引用常量而不是裸数字。public final class BizTimeBounds { // 本业务系统可受理的最小时间 public static final long MIN_TS LocalDateTime.of(2020, 1, 1, 0, 0) .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); // 本业务系统可受理的最大时间 public static final long MAX_TS LocalDateTime.of(2099, 12, 31, 23, 59, 59) .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); }这样做的好处是代码 review 的时候一眼就能看到边界定义而不用在堆满9999999999999的魔法数字里去猜含义。而且将来业务方把查询窗口调到 2099 年之外只需要改一个常量加一条注释所有引用它的地方同步生效。那次事故之后我把9999999999999当成了自己的思维锚点。每次看代码遇到极限数字都会先问一句这个值是怎么来的、它的边界定义在哪、从输入到存储之间有没有校验。代码里的极值本身不是问题真正的问题是极值被无声地当成了普通值一路畅通地进入核心业务逻辑。希望你读完这篇也能养成这个习惯。