一个定时任务从 14 分钟优化到 2 分钟
一个定时任务从 14 分钟优化到 2 分钟 “我会 Spring Boot”很普通,”我解决过一个线上任务内存 15G+、耗时十几分钟的问题”才有价值。这篇文章复盘一次真实的性能优化。 问题现象一个每天凌晨跑的数据同步任务,负责把外部系统的数据拉取、清洗、写入本地库。最初数据量小没感觉,随着数据积累到几百万条,问题集中爆发: 执行时间从 2 分钟一路涨到 14 分钟 内存峰值 15G+,频繁触发 Full GC 偶尔直接把服务 OOM 打挂 排查过程第一步:定位耗时点在任务里埋了几个计时点,发现 90% 的时间都花在”写入数据库”这一步,而不是拉取或清洗。 第二步:找到内存杀手用 JVM 的堆 dump 分析,发现内存里堆了 几十万个待写入的对象。原因是一开始的写法是”查一条、改一条、写一条”,后来为了”提速”改成了”全部加载进内存、最后一次性批量写”——结果把内存打爆了。 第三步:分析根因真正的根因是没有分批(batching)+ 逐条 UPDATE 没有走批量。数据量大之后,逐条执行的开销是指数级增长的。 优化方案1. 流式处理 + 分批不再把所有数据 load 进内存...
整洁代码与代码审查:写出别人愿意读的代码
有个说法我很认同: 代码是写给人读的,只是顺便让机器执行。 代码的正确性当然重要,但如果一段代码除了作者谁都读不懂,那它就是个”定时炸弹”——三个月后连作者自己都读不懂了。 这一篇是”基础系列”的收尾,聊一个软性但极重要的话题:怎么写出整洁、可维护的代码。 命名:代码可读性的第一道关命名的目标是:让人不读注释,光看名字就知道是什么意思。 几个原则: 用能表达意图的名字:d 不如 elapsedDays,process() 不如 calculateOrderTotal() 避免缩写:usrNm 不如 userName 布尔值用疑问句:isValid、hasPermission、isEmpty 方法名用动词:getUser、createOrder、validateInput 一个反例: 1int d; // 天数 一个正例: 1int elapsedDays; 好名字是免费的文档,不要吝啬。 函数:小而专一一个函数应该只做一件事,并且要短。 判断标准: 函数是否小到”一眼能看完”?理想是 20 行以内 函数是否只做了一件事?如果描述它需要”和”字,就该拆 函数的嵌套层级...
并发编程基础:线程、锁与内存可见性
如果说单线程编程是”确定性的世界”,那并发编程就是”不确定性的世界”。 多线程 bug 之所以难查,是因为它不是每次都复现——可能跑了 100 次都正常,第 101 次突然出错。 要驾驭并发,得先理解它为什么难。 并发问题的三大根源1. 原子性:一个操作被”打断”比如 count++,看起来是一步,实际上是三步: 读取 count 的值 加 1 写回 count 两个线程同时执行 count++,可能都读到 0,都加 1,都写回 1,结果本应是 2,却变成了 1。 这就是原子性问题:多个步骤的操作,在执行过程中被别的线程插入了。 解决:用锁(synchronized、Lock)或原子类(AtomicInteger)保证操作不可分割。 2. 可见性:改了但别人看不到每个线程都有自己的工作内存(缓存),变量修改可能先改在缓存里,没有及时同步到主内存。 结果就是:线程 A 改了变量,线程 B 却看不到,还在读旧值。 这就是可见性问题。解决:用 volatile 关键字,保证变量修改立刻对其他线程可见。 volatile 的作用是:保证可见性 + 禁止指令重排序。注意它不保证原子性,...
企业级微服务基础平台设计
企业级微服务基础平台设计 当公司同时有几十个系统在跑,每个系统都自己实现一遍登录、权限、菜单、数据同步,这就是巨大的浪费。基础平台的价值,是把这些重复建设收敛成一套统一能力。 为什么要做统一技术底座接手基础研发后我面对的现实是:公司内部巡检、安全、应急、设备、GIS、气量等业务系统,加上 ERP、客服、营收等外部系统,各自为战。同样的认证逻辑被写了十几遍,安全策略不统一,新项目交付周期长。 统一底座要解决三个核心问题:重复建设、安全不统一、交付慢。 通用能力的抽象经过梳理,我们把所有系统的共性能力抽象成下面几块: 能力 解决什么问题 统一认证 一个账号登录所有系统(SSO) RBAC 权限 统一的用户-角色-资源授权模型 多租户 不同客户/组织数据隔离 菜单集成 各系统菜单统一编排到门户 主数据同步 用户、组织等基础数据一处维护、多处使用 文件服务 统一的文件上传、存储、访问控制 消息通信 系统间异步消息与轨迹数据通道 定时任务 统一的任务调度与监控 这些能力每一个单独看都不难,难的是把它们收敛成一套稳定、可复用、可运维的体...
递归与回溯:从汉诺塔到全排列
递归是很多人学习路上的一道坎。 它的反直觉之处在于:你没法在脑子里完整地展开整个递归过程,越想越晕。 但其实递归有套路可循,掌握了套路,就能”以不变应万变”。 递归的三要素任何递归问题,都必须明确三件事: 递归的定义:这个函数要做什么?(返回什么、有什么效果) 递归的终止条件:什么时候不再递归,直接返回? 递归的递推关系:怎么把大问题拆成子问题? 以经典的斐波那契为例: 1234int fib(int n) { if (n <= 1) return n; // 终止条件 return fib(n-1) + fib(n-2); // 递推关系} 想递归的正确姿势新手最容易犯的错,是在脑子里展开递归树——“fib(5) 调用 fib(4),fib(4) 又调用 fib(3)……”,然后彻底乱掉。 正确姿势是只关注当前这一层: 假设子问题已经解决了(相信递归) 只思考”怎么用子问题的结果,得到当前问题的结果” 然后补上终止条件 就像数学归纳法:假设 n-1 成立,证明 n 成立,再验证 n=1 成立。你不...
二分查找:边界条件为什么总是写错
二分查找有个著名的梗:“一看就会,一写就错”。 甚至有人统计过,能一次写对二分查找的工程师不到 20%。 问题几乎全出在一个地方:边界条件。今天把它彻底讲透。 二分查找的前提二分查找只能用于有序数组(或单调序列)。 它的核心思想:每次取中间值,和目标比大小,直接排除一半。 1234567891011121314int binarySearch(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] == target) { return mid; } else if (nums[mid] < target) { left = mid + 1; } else { ...
排序算法:快排、归并、堆排,理解比背诵重要
排序算法大概是算法入门绕不开的第一课,也是面试里被问得最多的话题之一。 但很多人学排序的方式是”背代码”,结果背了忘、忘了背。 其实只要抓住每个算法的核心思想,代码自然就能写出来。 先记住这个底线基于比较的排序算法,时间复杂度有理论下界:O(n log n)。 也就是说,任何”靠比较大小来排序”的算法,最优也就是 O(n log n),不可能更快(除非用计数排序、桶排序这种不基于比较的特殊算法)。 所以快排、归并、堆排,都是已经摸到比较排序天花板的算法。 快速排序:分治 + 分区快排的核心是分区(partition):选一个”基准值(pivot)”,把数组分成两部分——左边都比 pivot 小,右边都比 pivot 大。然后对左右两部分递归做同样的事。 123456void quickSort(int[] arr, int lo, int hi) { if (lo >= hi) return; int p = partition(arr, lo, hi); // 分区,返回 pivot 位置 quickSort(arr, lo, p - 1);...
图与图的遍历:BFS、DFS 与拓扑排序
前面讲的都是”线性”(数组、链表)和”层次”(树)结构。但现实中有大量关系是”网状”的: 社交网络里,谁关注谁 城市之间,怎么导航 项目里,任务之间的依赖 这些用树表达不了,因为树不允许”环”,而现实关系到处是环。这时候就需要图。 图的基本概念图由**顶点(Vertex)和边(Edge)**组成。 按边的方向,分为: 无向图:边没有方向(如”互为好友”) 有向图:边有方向(如”A 关注了 B”,单向) 按边是否带权重,分为: 无权图:边都一样 带权图:边有权重(如”两个城市之间的距离”) 图的两种存储方式1. 邻接矩阵用一个二维数组 matrix[i][j] 表示”从 i 到 j 是否有边”。 优点:判断”i 和 j 是否有边”是 O(1),简单直观 缺点:空间 O(n²),稀疏图(边很少)会浪费大量空间 2. 邻接表每个顶点维护一个”它指向的邻居”列表。 优点:空间 O(V + E),稀疏图很省 缺点:判断”i 和 j 是否有边”要遍历 i 的邻居列表,O(度数) 工程上邻接表更常用,因为现实中的图大多是稀疏的。 广度优先遍历(BFS)BFS 按”层”逐层向...
