ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

程序员的数学思维:从排障到架构设计的底层能力

程序员的数学思维:从排障到架构设计的底层能力 1. 一场“幽灵”故障让我的数学思维开了窍都说程序员的数学是大学时欠下的债做业务系统十几年后再回头看真正拉开差距的不是能背多少公式而是被数学训练过的那套思维方式。程序员的数学说到底是用逻辑、抽象、建模和概率去处理复杂度数学思维则是面对一团乱麻时依然相信自己能把它拆成可证明的小块。这篇是《程序员的数学》系列的第二十七篇不打算再堆公式想聊聊数学思维如何在技术征途上持续回响。为了不让整篇内容悬在半空我先从自己经历里最扎心的一次线上故障讲起。1.1 诡异的偶发超时排除法是唯一能握住的东西那年我们维护一个支付回调服务监控面板上的P99从80ms涨到1.2s但问题只在高峰时段零星出现不是持续恶化也不是每次请求都中招。第一反应排查数据库慢查询慢查询日志开了一整天什么都没有第二反应看网络质量链路监控同样干净。团队陷入一种“不知道去哪儿找”的焦躁里有人开始怀疑监控数据打错了有人准备重启大法。第三天下午我在白板上把所有候选因素列成清单开始用数学里排除法的方式逐个证伪。假设是数据库锁竞争那么慢查询日志和锁等待事件里应该出现对应记录实际没有假设是磁盘IO抖动那么iostat曲线应该先出现毛刺实际也没有假设是上游500错误触发重试那么耗时曲线应该与错误率同相位但错误率曲线平滑根本对不上。列到最后剩下一个没人注意的配置服务内部把失败重试次数设成5次每次重试的退避时间写成了固定2秒而下游偶发900ms延迟导致多个线程在错误时刻集体进入等待。定位到这一步时我忽然明白什么叫“排除了所有不可能之后剩下的那个就是答案”。这次经历让我意识到数学思维不是考场上的技巧它真的能在你完全失去方向时给出一套可执行的推理顺序。从那以后我每次排障都会先问一句当前假设如果成立观测数据里应该出现什么而不是盯着屏幕凭感觉瞎猜。1.2 反证法、排除法与“让直觉变成命题”的习惯那次之后我刻意复盘自己的调试套路。数学里的反证法本质是先假设结论成立再推导出矛盾因此结论不成立。翻译成调试语言就是假设某个组件是根因那么日志或监控里一定存在某类特征去找这个特征找到就继续验证找不到就果断推翻。很多程序员调试是“凭直觉猜一个然后改代码试”没效果再换一个本质上是在碰运气。而用数学思维的人会把每次猜测写成命题并且先想清楚检验它的观测方法。这个习惯还有一个副产品就是学会“暂时搁置”一个假设。就像做证明题时你会先假设某个方向可行往下推几步直到出现矛盾再回头。我看到不少同事排障时花两小时研究一段日志只因为“感觉它不对劲”却不愿意用一条SQL验证它到底有没有问题。数学思维让人接受一个事实结论需要证据链而在证据链完整之前你手里的都只是假设哪怕它来自你最信任的直觉。后来每次评审事故复盘我都会建议大家把假设列表写下来逐一打勾或划叉这比争辩“我觉得是缓存问题”要高效得多。2. 把代码当成数学对象抽象、不变式与建模2.1 状态转移表与不变式重构订单状态机的意外收获有一回重构订单状态机旧代码里到处都是if嵌套看到第三层就头晕。我接手后第一件事不是优化if而是把所有状态和事件画成一张二维表行是当前状态列是可触发的事件单元格里填目标状态或“非法操作”。这个动作一完成那些散落的判断自动消失了。因为这张表本质上就是数学里的映射当前状态与事件组成的集合被映射到目标状态集合分类讨论因此变得非常清楚。第二件事是找不变量。库存扣减需求里有一个等式永远成立可用库存加占用库存再加上已售数量等于初始库存。任何一次扣减或退款本质上只是把量从等式的一侧移到另一侧。代码里并发操作之所以出错往往是因为某些路径只改了等式的一边。所以后来我每次写事务和加锁之前都会先把这种不变量写在设计文档里再去判断到底哪些地方需要原子性保护。这个习惯帮我避免了很多业务分支互相打架的问题模型的清晰度直接决定了代码的稳定程度。2.2 集合、函数与关系接口设计背后的数学骨架接口设计里最容易被忽略的是定义域。一个方法接收库存数量作为参数那调用方传入负数代表什么是非法输入还是特殊语义如果不定义清楚合法输入集合校验逻辑就会各写各的边界行为完全不可预测。数学里的函数定义方式其实就是最好的接口规范先写清楚定义域、值域、映射规则再谈实现细节。很多人写接口文档只写“这个方法用于扣减库存”却不写“quantity必须是正整数且小于等于当前库存”后端接口自然会被各种脏数据冲击。幂等性也可以用数学语言看得更透。一个操作如果是幂等的等价于f(f(x))f(x)重复作用等于作用一次。分布式系统里重试不可避免如果接口天然幂等重试就是安全的如果不是就必须引入去重机制。有了这层过滤镜你一眼就能看出哪些接口需要额外做幂等设计哪些不需要。数据库查询也一样关系代数里的选择、投影、连接本身就是集合运算理解它之后很多等价写法可以互相转换优化方向也会清晰不少。数学不会告诉你具体怎么写代码但会告诉你代码背后的结构是否成立。2.3 代码审查时多问一句“这段话的定理是什么”我现在做代码审查不太喜欢空谈“可读性不好”更常问的一句话是这段代码的前置条件和后置条件分别是什么翻译成数学场景就是在问一道证明题的条件和结论。对方如果能够清楚说出合法输入是订单号和金额金额必须大于0且小于订单剩余应付调用完成后订单状态只能从待支付变为已支付且支付流水唯一。那这段代码多半经得起推敲。反过来如果支支吾吾答不上来边界情况大概率没有考虑全。有一次审查账户余额扣减逻辑代码在并发场景下会重复扣款我当时的反应不是直接改而是追问前置条件里“用户余额必须大于等于扣减金额”在并发时还成立吗一查果然有两个线程同时读到余额然后都通过了校验。数学里的定义域、边界点、分类讨论听起来很基础但真正养成习惯之后能省掉大量线上事故。代码评审由此从“审美之争”变成“证明题验收”焦点自然落在了正确性上。3. 复杂度与增长用数学预估“未来”3.1 深分页为什么会越翻越慢累积成本是O(n^2)业务方经常提一个需求列表页支持翻到1000页。很多程序员直接写limit offset上线初期一切正常用户翻到几百页后就开始变慢。原因并不神秘offset99990, limit10的SQL在MySQL里实际要扫描前100000行再丢弃单次成本随offset线性增长。如果用户从第1页一直翻到1000页总成本近似是12...1000这是一个等差数列求和量级是O(n^2)。这不是SQL优化技巧这是数学里级数求和的思想。改进方案同样来自数学视角把线性增长变成对数或常数增长。键集分页比如where id last_id order by id limit 20每次查询通过索引直接定位扫描的数据量基本恒定。两相对比并不是哪种写法更“酷”而是看增长函数的形状。面对任何性能方案我现在都会先问一句它的成本随什么变量增长是常数、对数、线性还是指数问完这一句很多所谓的优化方向自己就分出了高下。3.2 均摊分析动态数组“偶尔卡一下”的真相Java的ArrayList和Go的切片扩容都会在容量不够时申请一块更大的内存并把旧数组复制过去。单次添加元素最坏情况是O(n)但连续添加n个元素的总成本是O(n)均摊到每次操作就是O(1)。这是均摊分析最经典的例子。懂了这个道理线上看到某次请求耗时突然高一点你就不会立刻怀疑线程池或GC而会去检查是不是集合扩容、消息批量打包这类“预期内的周期性代价”。这套思维还能迁移到容量规划。一个连接池如果定期做完全刷新高峰期刚好撞上刷新时刻单次请求延时就会出现毛刺。把事件放在时间序列里看很多毛刺根本不是真正的系统故障。用均摊的眼光去识别“结构性代价”和“随机异常”可以避免半夜爬起来做无意义的优化。数学里的平均成本分析放在工程里就是一套非常好的预期管理工具。3.3 用数学期望决定“要不要加缓存”最近评审报表接口团队争论要不要加缓存。我打开Excel算了一笔账接口QPS约1000数据库查询平均20ms缓存读约1ms。如果命中率能做到90%平均延迟大约是0.1乘以20加上0.9乘以1等于2.9ms看起来确实很诱人。但如果所有key都用相同TTL缓存集中失效那一刻会有接近1000QPS同时打向数据库可能直接打垮。这种场景下还需要估算“穿透放大倍数”而不是只看平均延迟。更稳妥的做法是给TTL设置随机扰动热点key单独做续期必要时加一层本地缓存挡住流量。如果只在“看起来快”上下决定很容易忽略概率模型里的尾部风险。数学期望不是用来做精确预测的而是逼着我们把“感觉”替换成变量之间的关系命中率、延迟、峰值流量、过期策略每个变量各占多少权重。这种估算做多了选型时自然多一层怀疑少踩很多坑。4. 概率与不确定工程决策中的理性利益4.1 重试不是越多越好先算放大系数有人喜欢在服务A调用B失败时设置“重试5次”以为这样能提升可用性其实可能制造雪崩。假设B的失败概率是p每次重试的失败又近似独立那么对B的期望请求次数是一个等比级数求和1 p p^2 ... p^5。当p等于0.5时这个值接近1.97当p等于0.8时接近4.69。失败率越高重试放大的请求倍数越大而B恰恰是在高失败率时最脆弱再加上多个调用方各自重试放大效应会叠加得更夸张。正确做法是限制重试次数、使用指数退避并给重试增加随机抖动避免所有失败请求在同一时刻涌向对端。这些设计的背后都有数学依据抖动是把集中的请求打散退避是降低同一时刻的碰撞概率限制次数是控制整体放大倍数。下一次有人提议“多发几次总能成功”时把这行等比求和写在纸上大家通常就能冷静下来重新讨论。4.2 A/B测试别只看均值方差和置信区间才是依据有一回产品同学跑了一个按钮颜色实验转化率从5.2%升到5.7%报告里写着“显著提升0.5个百分点”。我打开原始数据算了一下样本只有几千人转化率方差很大95%置信区间大约是-0.8%到1.8%包含了0。也就是说这个提升完全可能是随机波动。后来把样本量增加到几万区间缩小到0.2%到0.4%才敢认可实验结论。这个判断背后就是统计推断均值差、标准误、置信区间而不是简单比较两个百分比。程序员每天都在做隐含的A/B测试重构一个模块后感觉变快了换了一个数据库后感觉更稳了。如果不用数据检验很容易把随机波动当成真实收益。数学给我们的不是几个公式而是一种本能看到差异后先问这个差异是不是可能由噪声产生样本量够不够置信区间跨不跨零这是理性决策和“自我感觉良好”之间最本质的分水岭。4.3 承认不确定性系统反而更稳定很多系统事故源于一个隐蔽的假设故障不会同时发生。数据库不会宕机下游不会超时流量不会暴涨。概率思维恰恰反对这种断言。混沌工程的核心思想就是承认故障一定会发生只是时间不确定所以主动制造故障来检验系统的恢复能力。这就像数学里的概率模型不告诉你下一次扔硬币是哪一面但能告诉你长期频率稳定在二分之一你不该押注单次结果而要为长期分布做好准备。容量评估也是一样。如果只按平均QPS估算突发流量一来就必然拥塞。更合理的方式是看流量分布的高分位假设峰值时段是平均时间的3倍资源就必须按峰值预算而不是按均值预算。这种思考并不复杂难的是愿意承认自己的预测能力有限。一旦接受了这一点你会自然而然地给系统加上熔断、降级、隔离不是因为我们从众而是因为你知道数学上必然存在无法预见的尾部事件。5. 数学不好怎么练五个可以落地的日常训练5.1 写循环和递归前先写出“不变量”这道训练可能是所有训练里性价比最高的。写循环时不要急着敲代码先在注释里写一句“我每一步要维持的等式是什么”。比如求数组前n项和循环不变量就是“sum等于前i个元素之和”。代码写完后再检查循环体是否让这个等式从i成立推导到i1成立很多边界少一或多一的问题当场就会暴露。这个习惯说到底就是数学归纳法的雏形基础情况成立归纳步骤成立结论自然成立。# 求数组前 n 项和写出不变量 sum 0 for i in range(n): # 不变量sum sum(nums[0..i-1]) sum nums[i]递归同理写递归函数前先写清楚“这个函数在什么输入上返回什么”然后在递归分支里相信它可以用更小的自身实例解决。绝大多数递归写错的人恰恰是没有定义清楚最小子问题和收敛条件。编程里的很多边界bug根源都是没有把不变量写出来。5.2 调试时画真值表别用印象代替穷举条件组合类bug靠肉眼很难看全。数学里的真值表是穷举所有输入组合并写下预期输出这个方法放到调试中非常好用。比如某段逻辑有三个布尔条件A、B、C那就有8种组合。把代码执行路径按组合数列出来很快就能找到遗漏的那一行。很多资深测试会说这不就是测试用例设计吗没错它背后的理论基础就是命题逻辑。我自己写复杂条件判断之前会先建一张小表左边写输入组合右边写预期输出。测试时不再凭空给几个数据点而是尽量覆盖所有组合至少在逻辑层面把所有分支走一遍。这个习惯初期有点慢但它能显著减少“改了一下A分支B分支跟着炸掉”的情况。数学没有直接教我调试但它让我意识到完备性有多重要。5.3 读源码时给函数写“前提-结论”契约很多人读源码读不下去因为一头扎进实现细节。我通常先给函数写契约入参是什么范围调用前需要什么状态返回后承诺什么。然后带着契约去找实现中的对应证明。读一个缓存客户端时我先写“读缓存时如果key存在且未过期返回缓存值否则执行加载函数并写回缓存”再去看代码是否每一步都满足。如果发现代码某处不符合预期多半不是函数错了而是我对前提的理解有偏差。这种训练本质上是在练抽象思维把一段复杂代码压缩成前提-结论对再去验证。读得多了写代码时也会自然倾向于把函数拆成一个个契约清晰的小模块不需要靠大量注释堆砌。数学证明里“由已知推出未知”的训练其实一直在给这种能力打底。5.4 技术方案里用数字说话别用形容词你可以在评审会上做一个试验把方案里所有“提升明显”“性能更好”“扩展性强”替换成具体数字看还能不能站得住。比如“查询P99从250ms降到60ms”“单机QPS从800升到2000”。能写出来说明你想清楚了量化指标和验证方式写不出来说明方案本身还很模糊。这不仅是沟通问题更是数学思维里的测量与建模问题没有度量就无法比较和验证。我给自己定过一条规矩写技术方案时至少包含三组数字现状基线、目标值、验证方式。如果找不到基线就先做压测或补齐监控再动手。很多技术债务其实是在“没有数字”的情况下做出的选择累积出来的。把形容词换成数字之后评审效率会高很多因为大家讨论的不再是感受而是可验证的结果。5.5 复盘故障时画一棵故障树让根因分析有结构故障复盘最常见的错误是找到一个“直接原因”就停下来。比如数据库CPU飙高导致接口超时然后就只写整改项“扩容CPU”。但真正的问题可能出在上游循环依赖、慢查询、缓存失效等。数学给出的工具是故障树把顶层故障事件作为根节点下层用与门或门连接可能的原因逐层展开直到基本事件。每一层都要有证据支撑不能只靠“有可能”。一次线上故障我们用故障树梳理展开到第三层时发现一个或门分支被所有人默认“不可能”于是没人跟进处理。后来这个分支恰恰是根因。数学证明要求每一种情况都被讨论故障复盘也一样只要有一个分支没有证据它就应该被标记为待验证而不是被遗忘。画这棵树不需要复杂平台纸笔就行但遗漏率会低很多团队的复盘文化也因此变得更理性。6. 写在最后数学思维不是用来炫技的是用来兜底的前几天陪孩子做几何证明题孩子问我“为什么证明题这么难”我说“因为你得学会说因为所以给每个判断一个站得住的理由。”说完这句话我自己愣了一下这不就是我在这条技术路上磕磕绊绊十几年才真正学会的东西吗。程序员的数学也好数学思维也罢从来都不是为了在面试时显得学识渊博而是让你在未知、复杂、混乱面前仍然能稳住脚步问一句这里有什么必然成立有什么在概率上更可能是真的。这种能力不会因为框架升级而失效。今天写业务CRUD要用它明天设计分布式系统要用它再往后带团队做决策依然要用它。数学的公式会被忘记解题技巧会被工具代替但那种把大问题拆成可证明的小问题、把不确定变成可计算的概率、把每一次猜测变成可检验命题的习惯会在技术征途上永远回响。这也是我愿意把这一篇写下来当作这个系列第二十七篇的原因。
返回列表