
我盯着监控大屏上那串订单号足足看了三分钟。567890。它躺在海量请求日志里跟前后几十个订单号连成一条几乎笔直的递增线像一串被精心设计过的数字指纹。可就是这么一串“人畜无害”的编号让公司整个下午都陷入混乱用户领券全部报错“操作频繁”客服工单瞬间刷屏技术群里 我的消息一条接一条。排查到最后问题不在数据库也不在网关限流参数而是出在风控系统一个特别朴素的判断逻辑上——检测连续数字串。我们把大量真实用户请求的订单号拉出来一看567891、567892、567893……全是连续自增的风控直接把“批量遍历”的帽子扣在了正常流量头上一刀切限流。这篇文章就从这个数字串讲起。我会把这起事故的排查过程、编号生成背后的机制差异、以及一套我自己沉淀下来的“数字串问题”排查方法论完整拆给你。不管你是后端开发、测试、数据工程师还是做安全风控的这类问题迟早会碰到早点看到能少踩很多坑。1. 连续编号的“原罪”为什么 567890 会被风控盯上1.1 事发当天的完整时间线一次误杀是怎么发生的先把时间线摆出来这样你对整个事故的节奏会有个直观感受。14:00运营反馈线上领券入口大面积报错用户点击“立即领取”后提示“操作频繁请稍后再试”。14:10网关侧确认接口并非被大流量打满QPS 没有明显上涨排除 DDoS。14:30风控平台上出现告警一批请求被规则ids_sequential_attack命中被判定为遍历 ID 的批量扫描攻击进而触发了限流。14:50我们拉出被命中的请求明细发现订单号几乎连续567882、567883、567884……一直到 567890。15:30确认这些订单号全部来自真实用户下单并非攻击临时关闭该规则业务恢复。整个过程最讽刺的一点是规则设计出来是为了防坏人结果最先误伤的却是一群普通用户。因为数据库自增主键生成的订单号天然就是连续的。用户只要在同一秒前后触发两个请求拿到的订单号大概率就差一两位。风控系统把“连续”当成“恶意”的特征等于在无差别攻击自己的正常业务。事后复盘时我们再去看那批被拦截的订单发现它们不仅连续而且集中在同一个商品活动页。用户批量领取优惠券这个行为恰恰是活动方最希望看到的。规则却把它当成了异常这就是典型的安全规则与业务逻辑脱节。1.2 递增检测规则风控系统里最朴素也最误杀的一招为什么风控系统会盯上连续数字因为很多攻击行为确实以“遍历 ID”开始。早期的电商、社交平台里商品 ID、用户 ID、订单号往往都是自增整数攻击者只要把参数从 1 试到 10000就能把所有公开数据“扫”一遍。有些接口没有做越权校验扫一遍还不只是看数据甚至能修改别人的订单、领取不属于自己的权益。风控为了拦截这种行为慢慢地形成了一套启发式检测规则同一用户或同一 IP 在短时间内访问的 ID 序列如果呈等差递增/递减比如连续差值固定为 1就会被判定为扫描器。这套规则有效但问题在于它只看了“形”没看“神”。真正的攻击者会刻意绕开线性序列改成随机跳动、间隔采样甚至代理池轮换 IP而真实用户在下单、查券、翻页时产生的 ID却是老老实实地连续增长。于是规则变成了一把双刃剑攻击者稍微花点心思就能绕过正常用户反而成了最大受害者。那为什么这么粗糙的规则还会上线说穿了就是成本问题。在没有机器学习模型、没有设备指纹库的阶段判断维度实在太少连续序列是一个成本极低又容易量化的特征。可一个负责人的技术团队不该止步于此至少应该给规则叠加上下文比如是否登录、设备是否首次出现、请求频率是否真的异常。单一维度下结论出事是必然的不出事才是运气。1.3 连续编号在其他环节的连锁反应不只是风控会误杀“567890”这种连续编号引发的麻烦远不止风控拦截这一处。缓存穿透就是很典型的一个。如果某个活动页允许用户按订单号查询状态而缓存里没有这些新的连续订单号那么同一时间段的请求就会全部穿透到数据库造成瞬时压力。恶意攻击者甚至不需要别的技巧只要拿一串递增数字去打你就能把后端打哑。数据库分片热点也一样。很多系统用order_id % 16这类哈希分表正常情况下分布还算均匀。但如果某个活动爆发订单 ID 集中在极短的一段时间内连续签发这些 ID 在取模之后会落进同一个或少数几个分片最终形成“一串数字打垮一个库”的局面。还有更直接的数据泄露。公开接口如果返回了订单详情但不校验归属那么这些连续编号就是攻击者最好的字典。不需要猜从 567890 开始往下数 100 个能拿到多少人的隐私数据完全取决于你接口写得多草率。所以“编号可预测”这四个字听着像小事实际上可以一路捅到安全事件级别。2. 编号生成方式大比拼从自增到安全随机的选择逻辑2.1 你以为随便生成的订单号其实决定了系统稳不稳回到这次事故的根源最核心的问题不是风控规则而是我们“为什么还在用连续自增 ID 做对外业务编号”。我见过不少团队对编号生成的态度极其随意。数据库主键自增顺手就把它当作订单号返回给前端。内部用没问题但一旦这个编号出现在用户端、出现在 URL 参数里、出现在优惠券核销码里它的“可预测性”就立刻变成风险敞口。自增 ID 像一把钥匙坯每个人都知道它长什么样能不能打开门只取决于锁芯有没有做权限校验。更麻烦的是有些团队意识到问题开始用“时间戳 随机数”拼接订单号但随机部分用的是random()。这个函数产生的序列在知道种子之后是可以被推算的。如果种子恰好是当前时间戳攻击者只要锁定时间窗口就能还原出完整的随机序列。这等于把钥匙坯换成了外形更复杂但内部齿纹完全规则的另一把钥匙骗得过肉眼骗不过有心人。各方案优劣差别很大我整理了一个对比表方便你在做技术选型时直接参考。生成方案优点缺点适用场景数据库自增 ID简单、有序、索引友好可预测、易被遍历、暴露规模内部系统、纯数据库主键雪花 ID分布式可用、趋势递增时间部分可猜测、机器信息可反推分布式系统内部唯一 IDUUID全局唯一、随机性强长度大、无序、索引效率低无顺序要求的内部记录时间戳伪随机数可读性较好、生成简单随机性弱、种子泄露可预测低安全要求的展示编号安全随机数SecureRandom不可预测、抗枚举生成稍慢、长度较长优惠券码、核销码、token顺带提一句很多团队把“订单号”和“订单 ID”混为一谈。订单 ID 是数据库里的唯一主键可以自增但展示给用户的订单号、支付流水号应该单独生成或者对自增 ID 做混淆映射。把内部 ID 直接对外暴露是最常见的安全负债。2.2 随机数的“伪”与“真”567890 可能只是伪装得很好你可能觉得567890 这串数字看起来也挺随机的至少它没有 123456 那么扎眼。但随机性的核心不在于“看起来乱”而在于“不可预测”。真正的随机数应该来自物理熵源比如芯片级的噪声、温度波动。普通程序中常用的random()是伪随机数生成器它根据一个种子值通过算法推算后续序列。只要种子确定序列就是确定的。很多语言默认用当前时间做种子那么在同一个毫秒内创建的两个随机数生成器产生的序列可能完全相同这在高并发系统里是个致命陷阱。我之前在一家做营销系统的公司就见过真实事故优惠券码生成器用的是 Java 的Random服务器重启后同一个时间窗口内所有 JVM 实例生成了完全相同的券码序列。用户领到的券理论上大家都能用最后只能停机回滚。那是一次非常惨烈的 P0 事故根源就是“看起来随机”和“真正随机”之间的差距。判断一个编号系统是否安全可以很直观地问三个问题攻击者能否在短时间内枚举出所有合法编号能否通过观察历史编号推断下一个编号能否在不知道密钥的情况下伪造一个可通过校验的编号只要有一个答案是“能”这个系统就还不适合用在对外凭证类业务上。像 567890 这种连续串在没有做混淆和校验的情况下三个问题全中。2.3 校验位这层“防伪标签”很多系统根本没贴讲到编号生成有一个特别容易被忽视的机制校验位。身份证最后一位、银行卡号中间或末位、ISBN 书号背后都有一套校验算法。它们的作用很单纯在编号录入或传输过程中迅速发现常见的抄写错误、输入错位或者伪造尝试。很多业务编号尤其是不需要硬核加密、但需要防手误的凭证类编号完全可以加上校验位。最经典的是 Luhn 算法广泛应用于信用卡号校验。我拿 567890 跑了一遍这个算法结果它不通过校验。这就意味着如果这是一个需要用户手动输入的核销码用户一旦把 567890 输成 567891没有校验位的系统会傻乎乎地去数据库里查一个不存在的码而带校验位的系统可以在入口直接拒绝连数据库都不用碰。我这里留一段 Luhn 算法的实现你可以把它保存在工具库里以后设计编号系统时直接参考。def luhn_check(number: str) - bool: total 0 # 从右往左处理最后一位是校验位 for idx, ch in enumerate(reversed(number)): n int(ch) if idx % 2 1: n * 2 if n 9: n - 9 total n return total % 10 0 print(luhn_check(567890)) # False print(luhn_check(79927398713)) # True标准示例比起复杂的状态机校验校验位是一层几乎零成本、却非常有用的防御。设计编号系统时给它加一两位校验位能让脏数据在入口就被拦下省掉后续一堆脏数据治理的功夫。3. 三步复现“567890式”误杀现场附可运行代码3.1 构造最小复现用例让一段代码暴露规则误杀逻辑说一千道一万不如亲手复现一次。当时我们排查完写了一个非常小的 Python 脚本来验证风控规则确实会误杀连续数字。你先看代码逻辑它模拟的是“滑动窗口 差值恒定”检测。from collections import deque def is_sequence(ids, window5): 模拟风控规则滑动窗口内相邻ID差值恒定即判定为连续遍历。 window 表示连续几个数字会触发告警。 recent deque(maxlenwindow) for i in ids: recent.append(i) if len(recent) window: diffs [recent[j 1] - recent[j] for j in range(window - 1)] if len(set(diffs)) 1: return True, list(recent), diffs[0] return False, [], None # 模拟真实用户连续下单拿到的订单号 user_requests [567886, 567887, 567888, 567889, 567890] flagged, window, step is_sequence(user_requests) print(f是否触发规则: {flagged}) print(f被检测的窗口: {window}) print(f检测到的差值: {step})这段代码运行完输出会告诉你四个真实用户在下单时只要订单号被分到连续的 5 个风控就直接判定为扫描。问题在于我们的发号器本来就是按顺序发号的不同用户的请求在同一时刻到达拿到的编号天然连续。所谓“攻击特征”其实是系统自己制造出来的。3.2 边界条件与对抗样本设计怎么调规则才不会误伤复现出误杀之后我们开始设计更合理的检测逻辑。核心思路是单维连续特征不能直接作为拦截条件必须叠加多维信息。你可以把这个过程理解为警察办案不能看到两个人在同一个地点出现就抓人还要看是否有作案动机、是否有前科、是否在事发时间段出现。我设计过一个改进版的打分模型逻辑上大概是这样的def risk_score(user_behavior): score 0 # 1. 序列特征依然是重要信号但只加基础分 if is_sequence(user_behavior.order_ids): score 3 # 2. 登录态未登录用户风险更高 if user_behavior.is_anonymous: score 2 # 3. 设备新鲜度新设备短时间出现大量请求风险更高 if user_behavior.device_age 3600: score 2 # 4. 频率每分钟请求次数是否远超正常用户 if user_behavior.requests_per_minute 20: score 2 # 5. 行为一致性领券后是否立刻下单真实用户的转化路径更自然 if not user_behavior.takes_normal_path: score 1 return score # 只有综合评分超过阈值才拦截比如 6 分这个模型的核心改变在于连续编号只是众多特征中的一个不再具备一票否决权。真实用户即使订单号连续但因为处于登录状态、设备是老设备、请求频率正常、行为路径正常总分不会触达阈值。而真正的攻击者通常踩中多个风险项匿名、新设备、高频、无正常转化行为总分很容易超标。3.3 复现现场的关键步骤拿到日志后按这几步走回到排查过程本身我还想单独讲一下从日志里定位问题的方法论。因为很多时候你并不知道是风控拦的只看到“操作频繁”这种业务提示得一层层扒。第一步先拿到被拦截请求的完整参数。我们当时是从网关日志里按 traceId 反查出完整请求体再提取出订单号字段。第二步按用户维度做序列重组同一个用户在时间窗口内的订单号排好序计算相邻差值。第三步对照风控命中记录确认到底是哪条规则拦截的。第四步也是最关键的一步回到业务表里确认这些订单号是否真实存在于订单库、是否由正常的下单流程产生。最简单的统计脚本长这样你可以直接用cat access.log | grep -oE orderId:[0-9] | awk -F {print $4} | sort -n | uniq | tail -50拿到这段序列之后再配合 Python 脚本做差值分析。如果所有差值都是 1同时你确认这些请求来自真实业务那么基本可以判定问题出在规则设计而非流量异常。4. 避开这些坑排查工具、经验清单与行业实践4.1 我踩过的几个同类坑前导零、种子复用和数据倾斜除了风控误杀围绕编号的坑还多得很每一个我都亲手踩过。第一个坑是前导零消失。业务方给的券码是“012345”但导出 CSV 时 Excel 自动把它转成数字 12345核销的时候怎么都对不上。这不是 Excel 的锅是我们在导出时没有把编号列强制设为文本格式。后来所有凭证类编号的导出我都在字段前面拼接一个 tab 符或者用csv模块时明确设置quotingcsv.QUOTE_ALL才算根治。第二个坑是随机数种子复用。前面提到的 JavaRandom问题本质上是同一个调用链里多次new Random()而时间种子粒度太粗导致不同实例生成同一个序列。后来统一改成SecureRandom并且只在应用启动时初始化一次不能每次调用都新建实例。第三个坑是数据库分片倾斜。某次活动上线后订单量暴增但所有新订单 ID 都集中在某个区间导致对应的分片数据库 CPU 直接打满。做分库分表的时候如果按键的连续性太强一定要评估一下热点场景必要时可以在分片键计算里加入业务维度比如用户地域、渠道让数据分布更均匀。4.2 一张“数字串问题”排查清单先对症状再动手我把自己遇到的典型问题整理成了一张速查表排查时可以先对照一下能节省大量时间。现象可能原因排查手段常用解法大量请求被限流但 QPS 不高风控规则误判连续 ID拉取风控命中日志分析 ID 序列规则增加多维特征不能只看连续导出的编号变成科学计数法文本被当作数字类型打开文件检查单元格格式导出时强制文本格式或加占位符优惠券码出现大量重复伪随机数种子复用检查随机数生成器初始化位置改用安全随机数并全局单例某一分片数据库 CPU 飙高连续 ID 取模打到同一分片统计分片键分布分片键加入业务维度或改用一致性哈希按订单号查询缓存命中率极低连续 ID 导致缓存穿透观察缓存和 DB 的请求比例增加布隆过滤器或空对象缓存用户手动输入核销码频繁失败无校验位输入错误无法识别检查编号生成逻辑采用 Luhn 等校验位算法这张表不是万能药但大多数“编号异常”问题都能在里面找到影子。排查的范围一旦缩小解决问题的速度自然就上来了。4.3 团队落地建议把“可预测性”写进技术评审清单从 567890 这个数字串延伸出来的不应该只有一次事故复盘而是一套可以持续生效的规范。我强烈建议团队在技术评审时增加一个检查项系统中所有对外的编号是否具备不可预测性或者至少做了混淆处理。这应该和接口鉴权、数据加密一样成为默认要求而不是可选项。具体落地可以分几步第一梳理全系统有多少个“编号”字段区分哪些是内部主键、哪些是对外凭证第二对外凭证类编号统一迁移到安全随机数生成并叠加校验位第三风控规则全部改成多维打分不允许单特征直接拦截第四测试环境中不要长期使用“123456”“567890”这类连续数字造数据不然上线前的检查很难把它们跟真实测试数据区分开。这套规范看起来繁琐但它能挡住的不是一次事故而是一整类事故。毕竟系统的脆弱性往往不是从哪个高级漏洞开始的而是从一行看起来毫无攻击性的数字串开始的。从这串编号里我悟到的排查习惯最后再分享一点我个人的体会。这串数字在系统里躺了很久没人觉得它有问题直到它变成几万用户体验的导火索。排查做久了我慢慢养成了一个习惯拿到任何一串异常编号先别急着查代码而是先问它三个问题——从哪来、经过哪些系统、要被谁消费。把这三个问题查清楚问题往往就已经解决了一半。因为编号本身从来不孤立存在它是整条业务链路的缩影。你能把一串编号的生命周期看懂就等于把这个系统的骨架看懂了。这个思路不是看多少文档能学来的是靠一次次线上事故毒打出来的。希望这篇复盘能让你少经历一次那样的“毒打”。