恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Java抢票脚本高并发请求编排实战:线程池、限流与状态机
首页
资讯中心
/
Java抢票脚本高并发请求编排实战:线程池、限流与状态机
Java抢票脚本高并发请求编排实战:线程池、限流与状态机
发布时间:2026/10/11 10:57:39
简介这是一份面向Java程序员的12306抢票工具资料围绕高峰期火车票自动抢购场景提供一套可运行的自动化购票程序及配套说明。资源包内含1个doc文档压缩包约467KB以文字说明形式呈现程序的功能介绍、配置方法与使用要点便于快速理解整体逻辑。程序核心能力包括自动登录并填充账号密码、网络繁忙时自动重试、自动记录并刷新查询线路、指定车次优先预订、动车或卧铺优先选择以及验证码自动识别与手动输入切换同时支持通过config.properties配置代理服务器与目标车次并兼容Firefox配合Scriptish插件或Chromium核心Chrome。已有700人学习浏览适合希望了解自动化购票实现思路、借鉴网页爬取与验证码识别方法的Java开发者参考也可作为学习htmlparse等技术的实践案例。1. 抢票这件事Java 程序员到底能控制哪一部分每年春运前后后台总有人问我用 Java 写个抢票工具到底能不能跑赢手速先把结论摆在前面——你能控制的只有「请求怎么发、发多快、发多少次、失败了怎么退」你控制不了「票池里到底有没有票」。把这句话想明白后面所有代码才有意义。这个标题里的「秒杀」本质是一个高并发下的库存扣减问题固定数量的票海量请求同时到达谁先被系统受理、谁的请求参数最干净、谁的重试策略最合理谁就更可能拿到结果。Java 程序员做这件事有天然优势——线程池、连接池、HTTP 客户端、限流器这些轮子都现成难点不在写代码而在「怎么写得像个正常用户而不是像个攻击脚本」。适合读这篇的人有 Java 基础、懂 HTTP 协议、想把这套高并发请求编排能力用在真实业务压测或接口联调上的开发者。如果你只是想找个按钮点一下就能出票的东西那这篇不适合你因为下面每一段都在讲「为什么这么设计」而不是「复制粘贴就能用」。2. 先把请求链路拆开从登录态到提交订单的四个关键节点很多人一上来就写多线程循环提交结果跑十分钟就被风控拦了连自己错在哪都不知道。正确做法是先静态分析一遍完整链路把每个节点的「可优化空间」和「不可触碰红线」标出来。2.1 查票、选座、提交、支付哪一步才是真正的瓶颈一条完整的购票链路通常包含四个阶段阶段典型请求可并发主要风险查票查询余票接口可以但频率受限触发查询频率限制选座/占座提交订单前置谨慎并发重复占座导致订单异常提交订单创建订单接口单次为主重复提交被判定异常支付支付确认接口单次超时未支付自动取消新手最容易犯的错是把「查票」当成「抢票」来跑。查票接口的并发能力远高于下单接口你查一万次也不会出票反而会把自己的会话标记成异常。真正决定成败的是「提交订单」那一次请求的时机和参数完整性。我一般会这样拆查票用一个低频轮询线程负责感知「有票」这个事件一旦命中立刻唤醒下单线程用预先构造好的请求体提交。两者之间用阻塞队列传递信号而不是让下单线程自己空转查票。2.2 会话与令牌为什么你的请求总在第一步就被拒绝大多数购票接口都依赖登录态。登录态通常由 Cookie 或 Token 承载而这两样东西都有有效期和绑定关系。常见翻车场景是你在线程池里开了 50 个线程每个线程都拿着同一个 Cookie 去请求服务端一看「同一会话并发 50 路」直接判定异常。正确做法是控制单会话的并发度。一个会话同一时刻只允许一个下单请求在途其余线程要么等待要么用不同的合法会话。这里不展开怎么获取多个会话那是账号层面的问题和代码无关。// 单会话串行化提交避免同一 Cookie 并发触发风控 public class SerialSubmitter { private final Semaphore sessionLock new Semaphore(1); public String submit(OrderRequest req) throws InterruptedException { // 同一会话同一时刻只允许一个请求在途 sessionLock.acquire(); try { return httpClient.post(orderUrl, req, sessionCookie); } finally { sessionLock.release(); } } }这段代码的核心是Semaphore(1)它把「同一会话」的并发度压到 1。参数1不是随便写的——它对应「一个会话一条请求通道」这个约束。如果你有多个合法会话应该为每个会话各建一个SerialSubmitter实例而不是把信号量调大。2.3 用 HttpClient 还是 OkHttp连接复用的真实收益Java 11 之后自带java.net.http.HttpClient很多人纠结要不要引 OkHttp。我的经验是如果你只是发几十上百个请求两者差别不大但如果你要做持续轮询连接复用的收益就出来了。HttpClient默认会复用连接但前提是你用同一个实例。很多人每次请求都HttpClient.newHttpClient()等于每次重新握手延迟直接翻倍。正确姿势是全局单例配合连接池参数。// 全局单例 HttpClient开启连接复用 HttpClient client HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) // 部分接口对 HTTP/2 支持不稳定 .connectTimeout(Duration.ofSeconds(3)) // 连接超时别设太长 .executor(Executors.newFixedThreadPool(8)) // 控制底层线程数 .build();connectTimeout设 3 秒是经验值太长会拖慢失败感知太短会在网络抖动时误判。executor的线程数不要超过你实际需要的并发度开太大反而增加上下文切换开销。HTTP/1.1 是保守选择因为部分老接口对 HTTP/2 的多路复用处理不一致容易出现「明明发了请求却没响应」的玄学问题。3. 把并发控制写对线程池、限流器和重试策略的组合拳链路拆清楚之后下一步是把「发请求」这件事工程化。这一章讲三个东西怎么配合线程池决定你能发多快限流器决定你该发多快重试策略决定你失败后怎么办。3.1 线程池参数怎么定别再用 Executors.newFixedThreadPoolExecutors.newFixedThreadPool在生产环境是禁忌因为它用无界队列任务堆积时会 OOM。抢票场景虽然任务量没那大但习惯要养好。我一般直接new ThreadPoolExecutor把队列长度写死。ThreadPoolExecutor pool new ThreadPoolExecutor( 4, // 核心线程数常驻轮询线程 8, // 最大线程数突发下单线程 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue(64), // 有界队列防止堆积 new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程执行 );核心线程数 4 对应「查票轮询 少量下单」的常态最大 8 是给突发留的余量。队列 64 是拍脑袋定的吗不是——它约等于「一秒内允许排队的请求数」超过这个数说明你的策略有问题应该让调用方感知到压力CallerRunsPolicy而不是无限堆积。3.2 令牌桶限流把请求速率压到风控阈值以下风控系统通常按「单位时间请求数」判定异常。你不需要知道确切阈值只需要保证自己的速率「看起来像人」。令牌桶是最合适的模型允许短时突发但长期速率可控。// 简易令牌桶每秒补充 rate 个令牌桶容量 burst public class TokenBucket { private final double rate; private final double burst; private double tokens; private long lastRefill; public TokenBucket(double rate, double burst) { this.rate rate; this.burst burst; this.tokens burst; this.lastRefill System.nanoTime(); } public synchronized boolean tryAcquire() { long now System.nanoTime(); // 按经过时间补充令牌 tokens Math.min(burst, tokens (now - lastRefill) / 1e9 * rate); lastRefill now; if (tokens 1) { tokens - 1; return true; } return false; } }rate建议设在「每秒 1 到 2 次」这个量级burst设为 3 到 5。这个参数不是让你去卡风控的边界而是让你在长时间轮询时不至于因为速率恒定而被识别为机器。tryAcquire用synchronized保证线程安全抢票场景下锁竞争不激烈够用。3.3 重试不是无脑循环指数退避加抖动才不会被封失败重试是最容易写错的地方。无脑while(true)重试等于告诉服务端「我在攻击你」。正确做法是指数退避加随机抖动每次失败后等待时间翻倍再加一个随机偏移避免多个线程同时重试形成脉冲。// 指数退避 抖动最多重试 5 次 public long backoffMillis(int attempt) { long base 200L; // 基础等待 200ms long exp base * (1L attempt); // 200, 400, 800, 1600, 3200 long jitter ThreadLocalRandom.current().nextLong(0, 100); // 0-100ms 抖动 return Math.min(exp jitter, 5000L); // 封顶 5 秒 }base设 200ms 是因为大部分接口的瞬时失败在几百毫秒内就能恢复。1L attempt是位移实现翻倍比Math.pow快且无浮点误差。封顶 5 秒是防止重试间隔无限增长导致错过票池变化。抖动范围 0 到 100ms 足够打散线程间的同步。4. 避坑指南五个让抢票脚本当场翻车的细节这一章全是血泪经验每一条都对应我或身边人真实踩过的坑。现象、原因、解决三件套照着排查能省你几个小时。4.1 现象请求全部返回 200但一张票都没出原因你请求的是「查询接口」不是「下单接口」。很多接口返回 200 只代表「查询成功」不代表「下单成功」。下单接口通常有独立的 URL 和请求体且返回结构里会有明确的订单号或错误码。解决抓包确认下单接口的真实地址和参数不要凭猜测拼 URL。返回 200 时一定要解析响应体里的业务状态码而不是只看 HTTP 状态码。4.2 现象跑了几分钟之后所有请求超时原因连接池耗尽。如果你用的是自定义连接池且没有正确释放连接或者HttpClient的 executor 线程被阻塞任务占满后续请求就会排队直到超时。解决检查HttpClient是否全局单例检查每个请求是否都有超时设置检查线程池是否有任务泄漏。用jstack看线程状态如果大量线程卡在WAITING基本就是连接没释放。4.3 现象本地跑得好好的部署到服务器就失效原因服务器出口 IP 和本地不同部分接口对 IP 有地域或频率绑定。另外服务器时间如果和标准时间偏差过大签名类参数会直接失效。解决部署前先curl一下目标接口确认连通性用ntpdate校准服务器时间。签名参数对时间戳敏感的场景时间偏差超过几分钟就会全部失败。4.4 现象多线程提交后出现重复订单原因没有做幂等控制。多个线程同时提交服务端可能受理了多次或者你的重试逻辑在「请求已发出但响应丢失」时重复提交。解决下单请求带唯一业务 ID服务端一般会做去重客户端侧用AtomicBoolean标记「已提交成功」一旦成功就不再重试。重试只针对明确的失败响应不针对超时。4.5 现象程序跑着跑着 CPU 飙到 100%原因忙等待。常见于while(!hasTicket){}这种空转轮询或者令牌桶实现里没有sleep导致自旋。解决轮询之间必须有等待哪怕 50ms 也好。用Thread.sleep或LockSupport.parkNanos不要用空循环。CPU 飙高不仅浪费资源还会让你的请求节奏变得极不规律更容易被识别。5. 进阶技巧用状态机管理整个抢票流程前面讲的都是单点技术最后这一章讲怎么把它们串成一个可控的状态机。这是我从「脚本能跑」到「脚本稳定跑」之间最大的认知升级。5.1 为什么需要状态机把「碰运气」变成「可观测」没有状态机的脚本是这样的一个while循环里查票、判断、下单、重试所有逻辑揉在一起。出了问题你只能看日志猜。状态机把流程拆成明确的状态IDLE空闲、POLLING轮询中、HIT命中票池、SUBMITTING提交中、SUCCESS成功、FAILED失败。每个状态只做一件事状态之间的转移有明确条件。// 简化状态机每个状态只处理自己的逻辑 public enum State { IDLE, POLLING, HIT, SUBMITTING, SUCCESS, FAILED } public class TicketStateMachine { private volatile State state State.IDLE; public void run() { while (state ! State.SUCCESS state ! State.FAILED) { switch (state) { case IDLE: state State.POLLING; break; case POLLING: // 低频查票命中则转 HIT if (queryTicket()) state State.HIT; else sleepQuietly(500); break; case HIT: state State.SUBMITTING; break; case SUBMITTING: // 提交订单成功转 SUCCESS失败转 FAILED 或回 POLLING state submitOrder() ? State.SUCCESS : State.FAILED; break; default: break; } } } }这段代码的价值不在逻辑本身而在「每个状态可观测」。你可以在每个case里打点记录进入时间、停留时长、转移原因。跑一段时间后你能清楚看到「轮询了多久才命中」「提交失败了几次」「失败原因分布是什么」。这些数据才是优化策略的依据而不是拍脑袋调参数。5.2 用打点数据反推参数一个真实的调参过程假设你跑了一晚上打点数据显示平均轮询 3000 次命中一次票池命中后提交成功率 40%失败原因里 70% 是「请求过于频繁」。这说明什么说明你的轮询频率可能偏高导致命中时账号已经处于「被关注」状态提交自然容易被拒。调整方向把轮询间隔从 500ms 拉到 800ms 到 1000ms降低单位时间请求数同时把提交前的等待时间加一个随机 100 到 300ms避免「命中即提交」这种过于机械的节奏。调整后再跑一晚对比打点数据看提交成功率是否上升。这个过程没有标准答案因为不同接口的风控策略不同。但方法论是通用的先让流程可观测再用数据驱动调整而不是凭感觉改代码。5.3 我自己的习惯每次改动只调一个参数最后说个习惯。调参最忌讳一次改好几个地方改完有效果也不知道是哪个起的作用。我一般一次只动一个参数跑够样本量再动下一个。抢票场景样本量小一晚上可能就几次命中所以每次调整后至少观察两到三个完整周期再下结论。还有一点别把成功率当成唯一指标。有时候成功率没变但失败原因从「频率限制」变成了「库存不足」这其实是进步——说明你的请求已经被正常受理了只是票真的没了。看懂失败原因的变化比盯着成功率数字有用得多。希望帮到你。本文还有配套的精品资源点击获取