1. 工单标题叫“99999999999999”,我的第一反应是键盘进水了
周一早上,工单系统的消息弹出来时,我正端着咖啡准备看监控大屏。标题栏赫然写着“99999999999999”,优先级标了P2,指派给后端组。点开以后只有一行字:“运营后台的注册用户明细里,出现了一个用户ID是99999999999999,关联不到任何注册用户,麻烦排查一下。”
说实话,我第一反应是提问的人手滑,或者系统把数字ID渲染错了。做过后台系统的人都知道,这种“一长串重复数字”看着很像乱码,但在线上系统里,它往往不是偶然的——要么是有人拿它当测试数据填了表单,要么是校验规则放过了本不该放过的内容,再要么就是某个环节发生了类型转换,把一个错误值写进了字段。
这篇内容想把“99999999999999”这串数字背后的排查过程完整写出来:它到底是怎么进入系统的,14个9在技术上有哪些需要警惕的临界点,为什么遇到这种脏数据不能直接删,以及最后我加了哪些防线。如果你平时写接口、维护后台数据、或者做数据报表,这串数字的经历大概率能帮你省下半天排查时间。文中涉及的平台结构我会做必要简化,但排查思路和关键结论是完整可复现的,你可以直接套进自己的项目里对照检查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 14个9的背后:整型溢出、浮点精度与“看起来合法”的假象
2.1 先给这串数字做个体检
99999999999999,一共14位,十进制数值大约等于100万亿。在很多业务系统里,它不会是一个正常的注册用户ID,因为正常注册用户是按顺序自增出来的,没理由生成这种“全是9”的ID。但“不应该是正常ID”和“系统能不能存”是两件事。
我先把这串数字放到最常见的存储类型里过了一遍,结果整理成一张表:
| 存储/语言类型 | 有符号数值上限 | 能否容纳99999999999999 | 关键说明 |
|---|---|---|---|
| 32位整数(int) | 2,147,483,647 | 不能 | 超出约46倍,直接溢出或报错 |
| 64位整数(bigint/long) | 9,223,372,036,854,775,807 | 能 | 距离上限还很远 |
| JavaScript Number 安全整数 | 9,007,199,254,740,991 | 能 | 14位仍可精确表示,但接近临界 |
| MySQL INT | 2,147,483,647 | 不能 | 写入会报 Out of range |
| MySQL BIGINT | 9,223,372,036,854,775,807 | 能 | 常见 ID 字段选择 |
| Python int | 无限制 | 能 | 动态大整数,无溢出概念 |
| Excel 单元格 | 15位有效数字 | 能 | 再多一位就会变成科学计数法 |
这张表给了一个关键结论:这串数字在大多数“正经存储”里都放得下,但它恰好处在很多系统的“语义边界”附近——比32位整数大,逼近 JavaScript 安全整数的量级,也经常被拿来试探字段到底允许多长。换句话说,它不是一个“技术上存不下”的数字,而是一个“业务上不该出现”的数字,这种错位才是排查时最迷惑人的地方。
2.2 为什么“数据库能存”不等于“业务上该存”
排查时我注意到一个很有意思的细节。那张用户明细表里,user_id 字段用的是 BIGINT,按数据类型来说完全可以放下99999999999999。数据库层面没有任何报错,数据就这么安静地落库了。
问题恰恰出在这。我们的业务规则里,用户ID是由注册中心派发的,最长只有8位数字。数据库类型放得下,但业务上根本不应该出现14位ID。很多团队做数据建模时只盯着“类型能不能放下”,却忘了在应用层约定“业务上允许的取值范围”。这种缺失平时不会暴露,直到有人用一个超长数字把它捅出来。
所以从测试的角度看,“99999999999999”是一个教科书级的边界值用例。做接口测试的同学应该都听过边界值分析:选最小值、最大值、比最大值大一点、比最小值小一点的数据去测。而“一串9”因为输入方便,几乎成了边界测试的默认武器。这串数据能出现在线上,本身就说明系统的校验还停留在“基础类型正确”阶段,没有进入“业务范围正确”阶段。
2.3 浮点精度是另一个潜伏问题
有同事问过我,是不是 JavaScript 把数据搞丢了精度,才出现这个ID。我查完发现不是。99999999999999 小于 Number.MAX_SAFE_INTEGER(9007199254740991),在 JS 里是能精确表示的。但如果哪天用户手滑,多打了两个9,变成16个9,事情就不一样了——JS 会把它四舍五入成一个近似值,后面的位数直接变成0。
这类问题在联动场景里尤其可怕。我见过一个订单系统的真实事故:前端把一串16位的流水号存成 Number,再回传后端时后几位已经变成了0,导致对账失败。排查到最后,问题不在后端,而在前端“顺手”做了一次类型转换。
因此,遇到“99999999999999”这种超长数字,第一个排查原则是:在追踪路径上不要做任何不必要的类型转换,始终按字符串来处理。因为你无法确定源头环节是否已经丢过精度,只有保持字符串形态,才能保证后续比对不会二次失真。
3. 从网关日志到宽表字段:这串数字的完整旅行路线
3.1 先确认数据落到了哪张表
排查一开始,我没有直接去翻代码,而是先跑了一条查询:
sql复制SELECT * FROM user_account WHERE id = 99999999999999;
结果为空,说明它并不是真实用户,那它为什么能出现在“用户明细”里?我接着查了明细表的同步任务定义,发现“用户明细”是一张宽表,由多个数据源每天凌晨聚合生成。user_id 这个字段的来源并不是 user_account 主键,而是一张“活动报名表”里冗余的 user_phone 字段。
这个发现基本确认了我的怀疑:这串99999999999999,很可能是有人在前端报名表单的“手机号”输入框里填的。手机号被当成用户标识,原样冗余进了宽表,最终在报表里展示成了“用户ID”。
3.2 日志平台里定位到了那一笔请求
有了方向,我去网关日志里按“99999999999999”做全文检索。几分钟后,日志平台返回了一串记录:
- 请求路径:/api/activity/register
- 请求参数:phone=99999999999999
- 请求时间:上周四夜里 03:17
- 来源IP:一个归属地比较分散的IP段
- User-Agent:看起来像个脚本工具,不是正常浏览器
频率也不太对。同一个IP在十分钟内调了三十多次,每次 phone 参数都是不同的“连号”,比如88888888888、77777777777、99999999999999。到这里就能判断,有人在用脚本扫描接口,测试后端到底有没有做参数校验。
这里有个很容易被忽视的点:很多扫描行为,第一步不是上SQL注入或者越权 payload,而是先扔一堆边界数据看接口反应。如果接口把超长数字正常返回、正常落库,对方就会认为这个接口的校验很松,后面可能跟进更复杂的探测。所以遇到这种参数,不能只当普通脏数据清理掉,至少要记录 IP 和请求特征,判断是否构成批量扫描。
3.3 打开接口代码,校验漏洞一目了然
回到代码里,我终于看到了问题根源。报名接口对 phone 参数只写了一行校验:
java复制if (!phone.matches("\\d+")) {
throw new IllegalArgumentException("手机号只能包含数字");
}
这段代码只校验了“全是数字”,没有校验长度,也没有校验手机号段。于是“99999999999999”作为一长串纯数字,轻松通过校验进入了业务逻辑。更麻烦的是,后面的逻辑直接把 phone 当作用户唯一标识去关联,没做任何存在性校验。
类似的问题在表单开发里非常普遍。开发时通常只想到“用户不能输入字母”,却忘了“用户真的可能输入一长串9”。尤其是复制粘贴场景,用户从别处复制一段数字,可能带着空格、换行,甚至是一串根本不是手机号的长数字。如果校验只做正则匹配,不做长度限制,这类数据就会悄悄流到下游。
3.4 宽表同步为什么没有拦住它
如果说接口校验是第一道防线,那宽表同步任务应该是第二道防线。但事实是,同步任务里完全没有数据质量检查。它的逻辑非常简单:“从报名表取数据,清洗掉明显为空的字段,写入宽表”,既没有校验手机号格式,也没有校验 user_id 的取值范围。
这也是数据团队常踩的坑。离线同步任务跑得久了,大家都默认上游数据是“干净”的,很少有人在同步链路里加质量规则。而这串99999999999999用最直接的方式告诉我们:上游任何时候都可能出现奇怪数据,尤其当接口对外开放、任何人都能提交的时候,下游必须自己守好质量关。
4. 我为什么没直接删掉这条脏数据:关联与留痕的账要算清楚
4.1 “删掉”两个字听起来简单,风险却不小
发现问题的第一时间,数据组的同事问我:既然它关联不到注册用户,直接把这条报名记录删了不就行了?
我拦了一下。原因有四个:
第一,它不是孤立的一条。报名表里以 phone=99999999999999 为关联键,可能还挂了后续行为数据,比如领券记录、抽奖次数、埋点日志。如果直接删除主记录,这些子记录会变成无主数据,反而让报表更乱。
第二,如果表上有唯一索引,比如 uk_user_phone_activity(phone, activity_id),直接删除再遇到重复插入,会产生新的冲突,干扰后续排查。
第三,这串数据大概率是扫描脚本留下的,属于安全事件样本。直接删了就没了证据,等安全同事来分析请求特征时,什么都看不到。
第四,也是最容易被忽略的:删除操作本身会写 binlog,如果大批量删除,可能触发主从延迟,甚至在业务高峰影响线上性能。
4.2 先标记,再观察,最后决定归档
我采用的处理方式分三步。
第一步,把异常数据标记出来,而不是删除:
sql复制ALTER TABLE activity_signup ADD COLUMN is_suspicious TINYINT DEFAULT 0;
UPDATE activity_signup
SET is_suspicious = 1, suspicious_reason = 'phone_all_9_14_digits'
WHERE phone = '99999999999999';
第二步,检查关联表。我跑了几条 join 查询,确认这张表有没有被其他表引用,同时在日志平台里搜这个手机号出现过的痕迹,看看是否有下游任务依赖它。这个步骤花的时间不长,但能避免很多后续麻烦。
第三步,观察一周后,确认没有业务方来反馈这条数据导致的问题,再做归档处理,把 is_suspicious 置为1的数据原样移到历史归档表。归档表结构保持一致,只是在库名上加了一个 _archive 后缀,方便未来追溯。
有人可能觉得这套流程啰嗦,但线上数据维护最忌讳“手快”。我见过太多因为“删一条脏数据”引发的二次事故,所以宁可多花几分钟做关联查询,也不要凭感觉直接 delete。
4.3 这串数据还提醒了我:手机号加密与脱敏
查完关联关系,我顺手看了眼报名表的存储方式,发现 phone 是明文存储的。这又牵出一个更深的问题:如果手机号作为用户标识被到处冗余,一旦某个宽表泄露,影响面会被无限放大。
所以这次排查结束后,我提了一个改造计划:手机号在写入时做加密存储,展示时统一脱敏,内部关联改用用户内部的 uuid,而不是手机号明文。这个计划没法在一个工单周期内完成,但至少制定了一个原则:新表结构不再新增手机号明文字段,接口返回给前端时也统一走脱敏接口。
从“一串9”追到“手机号明文存储”,这个跨度看起来大,但实际排查里非常自然。异常数据往往是系统设计缺陷的冰山一角,顺着它往上游挖,多少能挖出一些平时注意不到的问题。
5. 三层防线与一条查询SQL:把“连续全9”变成可拦截特征
5.1 前端不能让用户随便粘贴一行键盘
我改的第一处是前端报名表单。手机号输入框加了 maxlength="11",同时加了一个 input 事件过滤,非数字字符直接不落值。之所以限制为11位,是因为当前业务场景只面向大陆手机号;如果你的系统支持国际手机号,可以放宽到15位,但一定要在提示文案里写清楚支持范围。
这里有个细节:光靠 maxlength 是不够的,因为用户可以绕过前端直接调接口。但加上它有一个实实在在的好处——绝大多数正常用户不会提交超长数字,只有脚本会。换句话说,前端限制解决的是“普通用户误操作”,真正的防线必须放在后端。
5.2 后端校验:正则、长度、业务枚举三件套
后端校验我改成了三段式,不再只靠一个正则:
java复制// 1. 空值校验
if (phone == null || phone.isBlank()) {
throw new IllegalArgumentException("手机号不能为空");
}
// 2. 格式与长度校验
String phoneRegex = "^1[3-9]\\d{9}$"; // 大陆手机号
if (!phone.matches(phoneRegex)) {
throw new IllegalArgumentException("手机号格式不正确");
}
// 3. 连续重复字符兜底
if (hasRepeatedDigits(phone, 8)) {
throw new IllegalArgumentException("手机号不能包含过长的连续重复数字");
}
hasRepeatedDigits 的实现很简单,遍历字符串,统计连续相同字符的最大长度,超过阈值就返回 true。这个规则不只能拦住99999999999999,还能拦住88888888888、6666666666这类脚本常用的连号。
更通用的做法是抽象一个参数校验注解,把“最大长度”“取值范围”“连续重复字符阈值”统一管起来。以后新增接口,不要在业务逻辑里顺手写校验,而是直接在入参模型字段上声明约束,这样每个外部输入都默认带上边界检查,不会再出现“只写了正则忘了长度”的半吊子校验。
5.3 数据链路质量规则:宽表同步不能只做空值清洗
我改的第三处是宽表同步任务。在同步 SQL 里加了一层质量过滤:
sql复制INSERT INTO app_user_daily_snapshot (user_id, user_phone, activity_id, ...)
SELECT
CASE
WHEN user_phone REGEXP '^[0-9]{11}$' THEN user_phone
ELSE NULL
END AS user_id,
...
FROM activity_signup src
WHERE src.is_suspicious = 0;
这里面的逻辑是:只有符合11位纯数字格式的手机号,才允许进入宽表的 user_id 字段;其他值一律置为 NULL,不允许进入业务报表。这样即使上游接口漏了,下游数据也不会再被污染。
有人会担心,直接置 NULL 会不会丢数据?我的建议是加一张审计表,把所有被质量规则拦截的数据记录下来,每天对账一次。既不影响业务报表,又能保留全量痕迹,后续要做数据回补也方便。
5.4 一条SQL定期扫描“连续全9”类文本
最后分享一个低成本高收益的巡检 SQL,现在我这里每天都跑:
sql复制SELECT
id,
phone,
REGEXP_REPLACE(phone, '(.)\\1{7,}', '***') AS digit_pattern
FROM activity_signup
WHERE phone REGEXP '(.)\\1{7,}'
LIMIT 100;
这个正则的意思是:连续8个及以上相同字符。它会把所有“99999999999999”“88888888888”这类异常输入捞出来,每天跑一次成本很低,却能在第一时间发现新的扫描特征。如果某个字段频繁出现这种值,说明上游接口一定存在校验漏洞,值得再去翻一遍代码。
5.5 从一次异常数据到一套边界校验习惯
这次工单修完之后,我还顺手做了一件事:把线上所有对外开放接口的参数校验清单拉出来过了一遍,重点看两类字段——数字型字段有没有限制最大值和最大长度,文本型字段有没有限制最小长度和最大长度。结果又揪出三个类似问题,都是“校验存在但不完整”的情况。
我个人在排查里最大的心得是:遇到“一长串9”,永远不要只修这一条数据,而是去问“它为什么能进来”。这串数字本身没有意义,但它进入系统的路径,会清楚地告诉你整条链路里哪些校验是缺位的。边界值检验、长度限制、质量过滤,看起来都是小事,但线上系统里所有奇葩数据,几乎都是被这些小事放进来的。
