
1. 先弄明白Loop Engineering 到底是什么1.1 一个让我真正理解这个概念的项目先讲一个真实发生在我这边的项目。去年接了一个营销活动后端任务系统要在活动结束后把九十多万条订单逐条做权益匹配、优惠券发放、奖励记录落库整体耗时必须控制在一个小时内。需求本身不复杂说白了就是一个大循环查订单、算权益、发券、标记状态然后继续下一批。但问题是——这个循环在测试环境跑一遍只要二十分钟一到生产环境就开始出幺蛾子。先是跑到一半有一批订单因为上游接口超时抛异常整个任务直接中断后来我加了重试结果某个用户权益接口连续返回失败循环在同一个订单上反复横跳把下游系统打到限流再后来我改成批量处理又发现数据库连接池被撑爆其他业务接口跟着一起抖动。那段时间我最大的感受是写循环的人很多但能把循环“工程化”的人不多。你可以在网上找到一万篇 for、while 的语法教程却很难找到一篇告诉你“循环跑一半崩了怎么办、重复执行会不会出事、几十万条数据怎么不拖垮数据库”的系统性内容。Loop Engineering 这个概念就是在这些实战问题里被逼出来的。它不是一个新框架也不是某个开源工具而是把“循环”当作一个完整的工程课题去对待怎么设计循环的结构、怎么控制循环的边界、怎么保证循环在任何异常下都不失控、怎么让循环可以有观察、有保护、有恢复能力。而我今天想写的就是一套保姆级的 Loop Engineering 方法论外加一个完整的项目实战过程。你可能是刚入门的新手也可能已经写过很多循环但没系统梳理过这篇文章的目标都只有一个让你下一次写完循环之后敢拍胸脯说“这个循环稳了”。1.2 我的定义循环进出的工程处理能力Loop Engineering 这个词直译过来就是“循环工程”但它并不只是“把循环写好”这么简单。我做过的项目里凡是循环出问题绝大多数都不是语法错误而是下面这几类问题循环没有边界保护极端情况下变成死循环循环体里某一步失败导致整个批处理任务中断循环重复执行时没有幂等保护数据被重复处理循环内调用外部接口没有重试上限把下游打挂循环处理大量数据时内存、连接池、数据库负载失控循环出问题时没有日志无法定位到底停在哪一条数据上你可以看到这些问题没有一个是“怎么写 for 循环”层面的它们全部发生在循环的“进出”环节——循环怎么开始、怎么继续、怎么停止、怎么恢复、怎么防止重复进入、怎么在异常时安全退出。所以我给 Loop Engineering 下的定义是对循环的进入、执行、退出、恢复、保护、观测这六个环节进行系统化设计和治理的能力。它不是某一个具体的技术点而是一套思维框架。掌握这套框架之后你再去看任何一段循环代码都会下意识地检查这个循环有没有最大次数限制失败之后能不能续跑重复执行会不会出问题跑得慢了能不能看到进度接下来我会先把循环的几种基础形态讲透再带你完整走一遍项目实战。这中间所有的设计取舍我都会解释“为什么”而不只是告诉你“怎么做”。2. 保姆级基础四种循环形态与适用场景2.1 批处理循环最简单也最容易失控先看最常见的一种形态批量处理循环。典型场景是定时任务、数据迁移、报表生成。伪代码长这样func BatchProcess(ids []int64) { for _, id : range ids { err : ProcessOne(id) if err ! nil { log.Errorf(process %d failed: %v, id, err) continue } } }这段代码看着没问题跑少量数据时也确实没问题。但数据量一旦上来问题就来了。第一个问题是内存压力。如果你把全部 id 一次性查出来放在内存里假设有五十万条每条结构体占 200 字节那光这个切片就要占 100MB 内存。这还算好如果你的查询条件复杂、关联表多内存占用会成倍上涨。我见过一个数据迁移任务一次性 load 了全量数据到内存然后逐条处理结果内存直接飙了几个 G最后被 OOM Killer 干掉。第二个问题是失败处理太粗糙。上面代码里continue虽然让单条失败不会中断整个任务但失败的数据没有任何记录。任务跑完之后你根本不知道哪些订单失败了、为什么失败。等到对账的时候发现少了三百条数据只能把日志捞出来一条条翻痛苦至极。第三个问题是数据库压力。循环里每处理一条数据就查一次库、写一次库五十万条数据就是五十万次查询、五十万次写入。如果单条耗时 30ms那单线程跑完要四五个小时而且这五十万次请求是串行打在数据库上的数据库连接池稍微配小一点就直接告警。所以批处理循环的工程化要点有三个分批而不是逐条、失败要落库而不是打日志、循环要有进度记录。这个问题我会在项目实战里用真实代码展开先留个印象。2.2 重试循环没有上限的重试就是雪崩第二种形态是重试循环。这个形态在调用外部接口、处理瞬时故障时特别常见。func RetryWithBackoff(attempt int) { maxAttempts : 5 backoff : 100 * time.Millisecond for i : 1; i maxAttempts; i { err : CallRemote() if err nil { break } time.Sleep(backoff * time.Duration(i)) } }重试循环的工程化核心在于必须同时有三个上限——最大重试次数、最大重试间隔、总耗时上限。并且重试要有退避策略不能失败后立刻重试。为什么必须有退避因为很多故障是有传染性的。如果你调用下游接口失败了一次立刻再打一次下游可能还在打嗝你还是会失败。如果这时候你有十个线程都在做同样的事每个线程都以最快速度重试下游瞬间就收到几十倍于平时的请求量本来只是轻微故障直接被你打成雪崩。指数退避是业界最常见的方案重试间隔按倍数增长第一次失败等 1 秒第二次等 2 秒第三次等 4 秒以此类推。经验值上指数退避的倍率取 1.5 到 2 之间比较合理倍率太大会让整体等待时间过长倍率太小又起不到错峰效果。另外还有一个我特别想强调的点重试循环一定要带 jitter抖动。如果你有 100 个任务同时进入重试它们会严格按照同样的时间点醒来然后同时打向同一个下游。所以第二批重试请求反而会比第一批更集中。解决方案是给退避时间加上一个随机偏移量比如乘以一个 0.5 到 1.5 之间的随机系数让每个任务的醒来时间错开。真实世界里很多连接风暴、缓存击穿都是重试不带 jitter 导致的。2.3 分页与游标循环深分页的终极解法第三种形态是分页循环。这个形态在处理列表类数据时非常常见也是最容易被人忽略性能问题的地方。很多人的第一反应是LIMIT offset, size但这里有一个巨大的坑。-- 第 100000 条记录开始取 20 条 SELECT * FROM orders ORDER BY id LIMIT 100000, 20;这条 SQL 的执行过程是MySQL 先把前 100020 条全部查出来然后丢掉前 100000 条只返回最后的 20 条。也就是说你要翻到第 100000 条数据库已经把前面十万条全查了一遍。翻页越深查询越慢而且不是线性增长是近似指数恶化。我之前在一个订单列表接口上做过测试offset 到十万之后单条查询耗时从 20ms 涨到 900ms接口直接不可用。游标分页也叫 keyset pagination是更工程化的解法。它的核心思路是不使用 offset而是记住上一次取到的位置下一次从那个位置继续往后取。-- 第一页 SELECT * FROM orders WHERE status 1 ORDER BY id LIMIT 100; -- 第二页记住上一页最后一条的 id 12880 SELECT * FROM orders WHERE status 1 AND id 12880 ORDER BY id LIMIT 100;因为id 12880能走主键索引数据库不需要把前面的数据全部扫一遍再丢掉性能非常稳定。不管翻到第几页查询耗时都能维持在几十毫秒级别。这里需要注意游标分页要求排序字段必须是唯一的、有顺序的、稳定的。最理想的就是自增主键id。如果你想按创建时间排序那么排序字段必须是create_time, id的组合并且查询条件要带上AND (create_time ? OR (create_time ? AND id ?))。很多人在这个细节上翻车后面我会单独展开。2.4 并发分片循环让循环长上“多条腿”第四种形态是并发分片循环。它的出现是因为单线程循环处理大量数据时太慢了需要并发来提升吞吐。最朴素的做法是直接开 goroutine 或者线程池然后把数据分成几片每个 worker 处理一片。这个思路是对的但坑在于并发度控制。如果你把并发数直接拉满比如开五十个 goroutine 同时打数据库数据库连接池可能只有二十个连接结果就是大量 goroutine 在等连接CPU 空转数据库那边连接被占满反而比单线程更慢。我的建议是并发数不要超过数据库连接池一半而且每个 worker 内部仍然要分批处理不要一次拿一条。比如连接池是 30那并发数先控制在 10 到 15。如果你是调用外部接口并发数的上限应该参考下游接口的承受能力而不是看自己机器的 CPU 核心数。并发还有个问题就是共享状态的同步。多个 worker 同时消费一批任务时必须保证同一个任务不会被两个 worker 同时拿到。最稳妥的方案是让每个 worker 各自持有独立的数据分片互不交叉如果需要动态取任务那就要用SELECT ... FOR UPDATE SKIP LOCKED这类数据库行级锁机制来保证任务分配的唯一性。这四种形态在真实项目里往往是组合出现的。比如批处理循环里嵌着重试循环分页循环经过并发改造变成并发分片循环。所以不要把它们当孤立的技巧来记而是把它们当成循环工程里的基础积木。接下来我会用完整项目把这几块积木搭起来。3. 项目实战活动订单批量处理任务全流程3.1 背景与需求90万订单要在一小时内安全处理完这个项目是一个典型的营销活动后处理系统。活动结束后系统需要遍历活动期间产生的全部订单依次完成用户权益匹配、优惠券发放、活动奖励记录落库、订单状态更新。数据量是九十多万条业务上要求一小时以内跑完而且必须在无人值守的情况下稳定跑完。这不是一个高并发实时系统但它对稳定性的要求一点都不低。原因有几条。第一任务跑错不能重来。优惠券发重了是资损漏发是客诉。所以每一步都要有幂等保护。第二任务中断要能续跑。九十多万条数据就算只要 30 分钟谁能保证中间不出一次网络抖动、不宕一次机第三任务不能影响线上业务。营销活动结束后还有其他接口在服务用户数据库的资源不能让这个任务全占了。基于这些约束我设计了下面这个方案数据分批拉取每批 500 条订单然后逐条处理使用游标分页代替 offset 分页避免深分页性能问题每批处理完成后记录游标位置支持断点续跑任务项表记录每条订单的处理状态保证重复执行不会重复处理外部接口调用带上重试循环用指数退避加 jitter 控制重试节奏并发度先压测确定而不是拍脑袋定这个方案就是我把前面四种循环形态组合起来之后的结果。下面我会把每一层的关键代码和设计原因讲清楚。3.2 表设计与任务状态机先看数据库表设计。这个项目的核心表就三张订单表、任务表、任务明细表。订单表是已有的业务表结构大致如下CREATE TABLE orders ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL COMMENT 用户ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 订单状态, activity_id bigint(20) NOT NULL COMMENT 活动ID, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_activity_status (activity_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;然后是我追加的任务表用来记录整个批处理任务的运行状态CREATE TABLE batch_task ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, task_name varchar(128) NOT NULL COMMENT 任务名称, total_count int(11) NOT NULL DEFAULT 0 COMMENT 总数据量, processed_count int(11) NOT NULL DEFAULT 0 COMMENT 已处理量, last_cursor_id bigint(20) NOT NULL DEFAULT 0 COMMENT 上次处理到的游标ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 任务状态: 0运行中 1完成 2失败, started_at datetime DEFAULT NULL, finished_at datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;以及任务明细表记录每一条订单的处理状态CREATE TABLE batch_task_item ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, task_id bigint(20) NOT NULL COMMENT 任务ID, biz_id bigint(20) NOT NULL COMMENT 业务ID即订单ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待处理 1成功 2失败, retry_count int(11) NOT NULL DEFAULT 0 COMMENT 重试次数, fail_reason varchar(512) DEFAULT COMMENT 失败原因, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_task_biz (task_id, biz_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;可能有朋友要问了为什么不直接用订单表加个处理状态字段我之前也这么干过后来发现问题很多。直接改订单表会侵入核心业务表加字段要审批、要兼容而且订单表的更新频率很高用订单表记录任务状态会让业务查询和任务处理互相竞争资源。单独建任务表和任务明细表把任务数据彻底隔离出去是最干净的做法。这里最核心的设计是batch_task_item表。它体现了循环工程里的两条关键原则可观测性和幂等性。有了明细表循环跑到哪里、哪些成功哪些失败一眼就能查出来任务重新执行时uk_task_biz唯一索引能挡住重复插入已经处理过的数据不会二次处理。任务状态机很简单PENDING - PROCESSING - SUCCESS/FAILED。这个状态机我建议放在代码里维护不要放在数据库里因为高频率的字段更新会让表膨胀、索引变大影响查询性能。3.3 核心循环实现游标分页 批量处理核心循环是下面这个样子的。我用的是 Go 伪代码但你换成 Java、PHP 一样可以理解。const batchSize 500 func RunBatchTask(taskID int64) error { // 1. 获取任务信息 task : GetTask(taskID) // 2. 从上次游标位置开始继续处理 cursorID : task.LastCursorID for { // 3. 游标分页拉取本批次订单 orders : FetchOrdersByCursor(cursorID, batchSize) if len(orders) 0 { break } // 4. 批量处理这一页的数据 for _, order : range orders { ProcessOrder(taskID, order) } // 5. 更新游标和进度 lastID : orders[len(orders)-1].ID UpdateTaskCursor(taskID, lastID, len(orders)) // 6. 如果这一页数据不足一批说明已经拉到尾部 if len(orders) batchSize { break } cursorID lastID } // 7. 标记任务完成 MarkTaskFinished(taskID) return nil } func FetchOrdersByCursor(cursorID int64, limit int) []Order { var orders []Order if cursorID 0 { // 第一页从最小 id 开始 DB.Where(activity_id ?, activityID). Order(id ASC). Limit(limit). Find(orders) } else { // 后续页只取 id 大于游标的数据 DB.Where(activity_id ? AND id ?, activityID, cursorID). Order(id ASC). Limit(limit). Find(orders) } return orders }这段代码的精髓就在id cursorID这个条件上。它比LIMIT offset优秀在哪我实际测试过同样是取第十万条附近的数据LIMIT 100000, 500这条 SQL 跑了 2.1 秒而id 98800 LIMIT 500只要 18 毫秒。这还不算最夸张的offset 越深差距越大。另一个容易被忽略的细节是循环退出条件必须有两个。一个条件是查不到数据了另一个条件是返回数量小于 batchSize。为什么需要第二个因为如果数据量刚好是 batchSize 的整数倍最后一批返回的数量等于 batchSize你没有第二个条件的话下一轮会再查一次空数据才退出虽然不会死循环但多一次无效查询。这里有人可能会说游标分页要数据保证是按 id 连续翻页的呀如果处理过程中有新订单插入会不会漏数据订单数据是活动结束后的存量数据不会有新写入所以没问题。如果你们的场景有增量数据那就需要给游标条件加上时间范围或者其他业务约束来保证快照一致性。3.4 并发改造与限流保护前面那版代码能正确跑完但单线程处理九十万条订单一小时未必够用。我实测单条订单的平均处理时间是 25ms主要是内部多次数据库读写和一次外部接口调用单线程跑完九十万条要 375 分钟远超一小时要求。所以必须做并发改造。但并发改造有一个很重要的原则只并发“拉取与分发”不要无限并发“处理”。我的方案是用控制通道配合 worker 池每个 worker 独立处理一批订单。func RunConcurrentBatch(taskID int64) { task : GetTask(taskID) cursorID : task.LastCursorID batchSize : 500 workerCount : 8 // 创建任务通道容量就是 worker 数防止无界堆积 batchChan : make(chan []Order, workerCount*2) // 启动 worker var wg sync.WaitGroup for i : 0; i workerCount; i { wg.Add(1) go func() { defer wg.Done() for orders : range batchChan { for _, order : range orders { ProcessOrder(taskID, order) } } }() } // 主循环拉数据投递到通道 for { orders : FetchOrdersByCursor(cursorID, batchSize) if len(orders) 0 { break } batchChan - orders lastID : orders[len(orders)-1].ID UpdateTaskCursor(taskID, lastID, len(orders)) if len(orders) batchSize { break } cursorID lastID } close(batchChan) wg.Wait() MarkTaskFinished(taskID) }把batchChan的容量设为 worker 数的两倍这个设计是有讲究的。如果通道容量太小主循环拉取数据的节奏会被最慢的 worker 卡住如果容量太大主循环会提前把后续所有数据都拉进内存内存压力大。两倍是我压测下来比较合适的值既能平滑波动又不会吃掉太多内存。并发数为什么选 8不是随便拍的。我当时做了个小压测并发 4 的时候处理速度每分钟约 1.2 万条并发 8 的时候能到每分钟 2.1 万条并发 16 反而掉到了每分钟 1.6 万条——因为数据库连接池被打满大量协程在排队等连接。所以我最后定了 8既能跑进 30 分钟又留了余量给线上其他业务。外部接口的重试循环也要比平时更克制。批处理任务调用外部接口单条失败重试三五次是可以的但如果并发 8 个 worker 同时重试下游承受的瞬间压力就是 8 倍。我在重试逻辑里加了信号量来做全局限制同一时刻最多只有 20 个外部调用在途让下游始终在自己的承受范围内。3.5 中断恢复与幂等验证现在到了这个项目最关键的一环任务中途挂了怎么恢复不做中断恢复的批处理很常见跑挂了就手动改个偏移量重新跑或者干脆全量重跑。但九十多万条的优惠券发放任务全量重跑一次要半小时而且有重复发放的风险。所以这个项目从一开始就把“断点续跑”设计进去了。续跑的逻辑非常简单任务表里的last_cursor_id就是断点。每次跑完一批就把这一批最后一条订单的 id 写回任务表。下次任务启动时直接GetTask(taskID)从LastCursorID继续往后拉就行了。这里有一个非常有迷惑性的细节任务进程重启后已经从批处理循环里处理过的订单它们的后续操作不会重复执行吗会的。比如进程处理完订单 A 的优惠券发放还没来得及把状态写进batch_task_item表就宕机了那订单 A 可能已经被处理过了。任务重启后不会再去处理 A 吗会。那会不会重复发券所以光有游标续跑还不够必须给每一项业务操作加幂等。我在ProcessOrder里加的幂等策略是先查batch_task_item发现这条订单已经成功就跳过如果发现是失败状态则根据失败原因决定是否重试。状态写入和业务操作放到同一个数据库事务里保证“处理成功”和“标记成功”要么都完成要么都不完成。这里还要注意一个太过细但很关键的问题不能只“先查再处理”用查到的状态来挡重复是不够的。因为并发场景下两个 worker 可能同时拿到同一个订单同时查状态都发现是待处理然后都去执行发券。解决方式是给batch_task_item的uk_task_biz唯一索引做兜底插入时如果冲突就说明已经处理过。最终的幂等完整链路是进入ProcessOrder先尝试插入batch_task_itemINSERT ... ON DUPLICATE KEY UPDATE如果插入时发现已存在且状态为成功直接 return只有插入成功的这个 worker 才继续执行后续业务业务操作和更新batch_task_item状态放到同一事务里这套方案跑下来我再也不怕任务中途宕机了。实测模拟过进程在随机时间点被 kill 的场景重启后都能精准续跑没有多发一笔券也没有漏掉一张券。4. 实操过后的四个关键心得4.1 循环一定要有“逃生门”这个心得是我这次项目里最大的感受。所谓逃生门就是给循环一个在极端情况下能主动退出的机制。我见过太多循环代码只有正常的退出条件比如for i len(list)。但如果 list 因为某种原因永远不为空呢如果游标因为数据问题永远前进不了呢如果重试循环永远失败呢没有逃生门的循环要么无限空转占满 CPU要么把下游打到雪崩。我在每次循环迭代里都加了一个最大迭代次数保护。这个上限可以设在业务合理值的五倍以上正常情况永远不会触发但一旦出现异常它能保证循环一定会在有限时间内停止给后续的告警和人工介入留下窗口。另外还有一个非常实用的逃生门循环进度探针。我在批处理循环里记录每次迭代的耗时一旦发现连续超过 10 批数据的处理耗时都比正常值高出三倍以上就主动暂停循环并发出告警。这在数据库出现锁等待、外部接口变慢的场景下特别管用能避免任务在“半死”状态下继续消耗资源。大多数循环问题都是慢而不是直接挂掉逃生门的作用就是发现“慢得不对劲”的时候及时跳出来。4.2 批量大小要靠压测不要拍脑袋很多人写批处理循环时喜欢用 100、1000、10000 这种整数作为批量大小理由往往是“差不多”或者“看着合适”。我在这个项目里做过一组对比测试数据是最有说服力的批量大小总耗时数据库连接峰值外部接口压力结论10045 分钟低低太保守耗时超预算50027 分钟中等中等推荐方案100026 分钟较高较高收益不明显风险上升500023 分钟高高出现过数据库连接等待这个测试结果很有典型性批量从 500 涨到 5000耗时只减少了 4 分钟但数据库和接口的压力增加了好几倍。因为单批处理量越大事务越长锁持有的时间越长其他业务等待的时间也就越长。批量处理选择的逻辑应该是在“吞吐量不错”和“资源占用不高”之间找一个甜点区而不是单纯追求最大吞吐。我做这个项目时定的标准是单批处理耗时不超过 10 秒、数据库连接占用不超过连接池一半。满足这两个条件的最大批量就是当前环境下的最优批量。4.3 日志与监控是循环的“仪表盘”循环代码本身的正确性只是第一步。前面的项目如果没有任何日志我根本不可能在线上环境里定位到“处理到第 38002 条订单时用户权益接口连续超时”这件事。这次项目所有循环输出都带上了三个固定信息任务 ID、当前批次、当前业务 ID。日志格式类似[task_id1024][cursor78000][order_id78334] batch process start。为什么这么设计因为任务并发跑起来之后日志是多个 worker 交错输出的如果每行日志不带上下文根本无法区分是哪条订单、哪个批次产生的记录。监控方面我额外记录了几个指标每秒处理订单数判断处理速度有没有掉当前批次耗时判断是不是某一批卡住了重试次数分布是不是有大量订单在不断重试失败订单数是不是有某种失败在快速增长这些指标都打到 Prometheus配了简单的告警规则。一旦“每秒处理数”连续五分钟低于正常值的百分之二十就触发告警。有了这套仪表盘循环跑得健康不健康远程一眼就能看出来不用等用户报告“怎么活动奖励还没发到”。4.4 代码评审时先看循环再看业务这个心得我想重点说一下。以前我 review 代码习惯先看业务逻辑对不对、字段有没有写错、接口有没有调对。后来在做循环工程自查时我发现业务逻辑问题往往只会影响单条数据的正确性而循环结构问题会把影响放大到整个数据集。一条订单发券逻辑写错了最多影响这一单。但循环退不出来影响的是九十万条数据整个任务全部不可用。所以我现在 review 代码的顺序变成了先看循环再看业务。具体检查点包括循环的进入条件是什么有没有可能永远进不来循环的退出条件是什么有没有可能永远出不去循环内部有没有 break、continue、return这些跳出逻辑会不会跳过必要的清理流程如果循环体里有异常异常被吞掉了还是上抛了会不会导致循环状态不更新而重复执行循环有没有超时控制或最大执行次数循环有没有日志日志有没有带上下文标识这些问题逐个过完再回来看业务。按这个顺序 review 了几个项目之后我发现找到的潜在问题比以前多了一倍而且都是那种“不爆则已、一爆就是事故”的问题。5. 常见问题与排查技巧实录5.1 死循环把数据库打满怎么快速定位死循环是循环工程里最让人头疼的问题但它通常不是突然出现的而是慢慢恶化的。我遇到过一个真实案例某个任务在数据量变大后游标位置更新失败因为那批数据里有一条写入超时结果游标一直停在同一个位置外层循环反复拉取同一批数据数据库 CPU 被打到了 100%。当时我排查的步骤很有参考价值。第一步先去监控看数据库的 SQL 明细发现某一条SELECT ... WHERE activity_id ? AND id ?被高频执行每秒执行了几百次这就锁定了死循环的位置。第二步看这条 SQL 的入参发现游标值一直不变判断是游标更新逻辑有问题。第三步检查任务日志发现这批次里有几条订单处理超时导致整批的处理结果没有被回写。根因清晰之后修复方案就是给每批次单独设置超时时间而不是让整个批次无限期等待。这里可以给大家一个定位死循环的速查思路死循环执行的都是同一条 SQL、同一个外部接口、同一段计算逻辑所以监控里的“QPS 平直线”“接口调用量不再增长”“CPU 长期打满”这三个现象同时出现基本可以断定是循环卡住了。解决死循环的最好办法是预防进入循环时设置最大迭代次数迭代次数到了就强制退出并告警。5.2 同一笔订单被处理两次幂等怎么根治重复处理问题看起来是逻辑问题实际上很多情况下是并发竞态问题。我前面说的方案里用了唯一索引 事务但实际操作中还有一个容易忽略的细节唯一索引冲突时的事务处理顺序。如果你先在逻辑里“查了一下发现没处理过”然后去执行发券最后再插入记录这在并发下是不安全的。必须先插入占位记录再执行业务操作最后更新状态。如果业务操作失败再把记录状态改成失败。这种做法听起来有点反直觉因为要先插入一条“待处理”状态的数据然后才去干活。但它能保证同一时刻只有一个 worker 能成功插入记录其他 worker 看到唯一的记录已经存在自然就不会再执行了。这是数据库层面最可靠的幂等方案。如果你们用的是消息队列消费模式道理也一样消费者拿到消息后先尝试写入处理记录写入成功的那个消费者才真正处理业务。其他消费者重复收到消息时会因为记录已存在而跳过。服务重启、消息重投、并发消费这三种最容易造成重复执行的场景都被唯一索引兜住了。5.3 OFFSET 翻页越来越慢该怎么办这个问题的答案其实在前面已经给出了换成游标分页。但如果你暂时改不了代码可以先做一个过渡优化——限制最大翻页深度。具体做法是如果请求的 page 和 size 计算出的 offset 大于一个阈值比如 5000就不走正常查询而是返回一个空结果或者提示用户缩小查询范围。这个方案不优雅但能防止深分页把数据库打挂给自己争取改造时间。如果业务上确实需要支持任意深度翻页除了游标分页之外还有一个方案是走搜索引擎把订单数据同步到 ES由 ES 去扛深分页。但这个方案引入的额外成本比较高除非业务场景真的绕不开否则我建议还是优先游标。5.4 单条失败拖垮整批任务的处理策略这个问题在大数据量批处理时特别普遍。我见过一个数据修复任务处理到第 900 条时遇到一条脏数据抛了个异常整个批次直接回滚前面 899 条全部白干。重新跑一遍还是卡在同一条上。处理策略很简单单条失败一定要和整批失败解耦。批处理循环里的异常粒度应该控制在“每一条业务数据”的级别而不是“整个批次”的级别。我项目里的做法是ProcessOrder内部捕获所有异常把失败原因写进batch_task_item然后返回一个带有错误信息的结构体由外层循环决定是继续下一条还是重试当前这条。只有整个批次被外部因素干扰比如数据库连接断了时才允许整个批次停止。此外我还要强烈建议给批处理任务加“失败隔离舱”如果一个订单连续失败超过设定次数我这边是 3 次不再继续重试而是把这条订单标记为“需要人工介入”由专门的补偿任务去处理。这样一个脏数据、一个坏接口就不会拖住整个任务。血泪教训是如果在一条失败数据上反复横跳那这个任务永远跑不完。6. 保姆级落地清单从0到1做好Loop Engineering6.1 八个必检项的循环开发检查表如果你现在要写一个带循环的任务我建议对照这份清单自查。这八项是我做循环工程时反复使用的检查表覆盖率超过了实际项目中绝大多数循环问题。检查项具体要求检查结果循环进入条件清楚知道循环什么时候开始初始游标/初始索引是否正确已检查循环退出条件至少两个退出条件包含数据耗尽和最大迭代次数已检查游标更新时机游标必须在每批次处理完毕后更新不能放在批次中已检查单条失败隔离单条异常不允许中断整个批次失败原因落库已检查重试上限所有重试循环必须有最大次数和退避策略已检查幂等保护唯一索引或分布式锁保证重复执行不产生副作用已检查日志上下文每行日志都带任务ID、批次ID、业务ID已检查进度可观测有记录已处理数量、当前游标、处理速度的手段已检查这八项全部通过循环的稳定性就有了基本保障。6.2 小白也能上手的日志打点模板很多刚入行的朋友最大的困惑是“日志到底该怎么打”。我贴一个我常用的模板直接拿去改改就能用。// 批次开始 log.Infof( [batch_loop] task_id%d cursor%d batch_size%d start, taskID, cursorID, len(orders), ) // 单条处理结果 log.Infof( [batch_item] task_id%d order_id%d status%s cost_ms%d, taskID, order.ID, status, costMs, ) // 失败记录 log.Errorf( [batch_item] task_id%d order_id%d error%v retry_count%d, taskID, order.ID, err, retryCount, ) // 批次尾部进度 log.Infof( [batch_loop] task_id%d cursor%d processed_total%d speed%.0f/s, taskID, lastID, processedTotal, speed, )日志格式里最关键的是task_id和order_id。有了这两个字段你在日志系统里搜索的时候一眼就能定位到某一条订单的完整处理过程而不是在密密麻麻的日志里翻半天。6.3 最后的一点个人体会Loop Engineering 这个概念听起来很高级但落到实地上它就是一组朴素的工程习惯循环要有边界、要有退出机制、要能续跑、要防重复、要可观测、要防雪崩。这些习惯分开来看都不难难的是把它们内化成写循环时的本能反应。我自己的体会是写过一万行业务代码不如认真把一个循环的边界条件想透一次。因为业务代码的错误最多影响一条数据循环的错误会影响所有数据。做循环工程之后我每次写循环都会先问自己几个问题这个循环如果永远不结束怎么办如果跑到一半挂了怎么办如果重复执行会怎样如果下游变慢了怎么办把这些问题想清楚代码的稳定性自然就上来了。未来你肯定会遇到更复杂的循环场景跨服务编排的循环、流式计算里的循环、甚至 AI Agent 里的工具调用循环。到那时候你会发现今天学到的游标、幂等、退避、逃生门这些基础招式换个场景照样能用。它们不是某个项目的专用技巧而是循环工程里永不过时的底层逻辑。