news 2026/10/17 16:50:24

虚拟线程在Spring Boot 4中的高并发实战:秒杀与批量处理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
虚拟线程在Spring Boot 4中的高并发实战:秒杀与批量处理

1. 先把结论放前面:虚拟线程到底解决了什么

虚拟线程是我做后端这些年觉得最值得跟进的一个 JVM 特性,不是因为它性能比线程池高多少,而是它彻底改变了写高并发代码的姿势。以前一个 Tomcat 线程默认处理一个请求,线程数一多,上下文切换就能把 CPU 烧掉大半。现在虚拟线程把一个阻塞操作的成本压到了极低,真正做到了"每个请求一个线程,不够就开,开了也不心疼"。

这次的工程实战,我把虚拟线程结合 Spring Boot 4 用在了两个最典型的场景上:一个是秒杀,特征是短促、密集、极度依赖 Redis 和数据库;另一个是批量处理,特征是海量小任务、长时间占用、需要逐步推进。两个场景刚好覆盖了虚拟线程的优点区间,也暴露出一些必须绕开的坑。这篇文章把这些经历完整拆出来,代码、配置、参数、踩坑按顺序讲清楚,希望能帮你少走点弯路。

适合谁来读:已经在用 Spring Boot 3.x,想了解 Boot 4 里虚拟线程怎么落地的人;以及那些正准备把线程池换掉、但又不确定换了会不会翻车的开发者。下面所有内容都基于实战记录,配置和代码都可以直接抄。

2. 整体设计思路:线程模型选型背后的三个关键判断

2.1 为什么这个时代需要重新考虑线程模型

经典线程模型的根本问题在于:一个操作系统线程对应一个内核调度实体,线程创建成本高、切换成本高、内存占用高。默认线程栈是 1MB,起步 1 万个线程就要吃掉 10GB 左右虚拟内存。所以以前我们用线程池,本质上是"控制并发数量的无奈之举"。

但线程池带来一个问题:池子里线程的数量通常不会很大,几十个到几百个。当大量请求同时进来,它们不光要等待 CPU 执行,更多时间是在等 Redis 返回、等数据库连接、等远程接口响应。这个等待时间对操作系统线程来说就是白白的闲置。

虚拟线程的做法很直白:把"执行代码"和"底层系统线程"解耦。虚拟线程的调度由 JVM 自己完成,阻塞操作时虚拟线程会被挂起,让出载体线程给其他虚拟线程用。于是你可以在 JVM 里同时存在几万个甚至几十万个虚拟线程,却只占用几十个载体线程。这就像把高铁的座位改成共享的,谁到站谁就让座,候车的人随到随补。

2.2 秒杀和批处理为什么适合放在同一套方案里

秒杀场景的特点是:并发峰值高,但每个请求做的事情很少。校验库存、扣减库存、生成订单,核心逻辑可能只需要几毫秒。真实瓶颈往往在 Redis 和数据库的响应延迟,如果每个线程只是等待,传统线程池很容易被"等待中的线程"占满。

批量处理场景的特点是:并发不高但单个任务链路长。比如要把几十万条数据逐条调外部接口校验,每条耗时几百毫秒到几秒不等,总体耗时取决于并发度。用固定线程池,并发度是明确的;但如果想临时把并发拉高,只能改配置重启,不够灵活。

这两个场景拼在一起正好覆盖了虚拟线程的两个核心优势:高并发下低成本挂起,以及随时随地按需开新线程而不用担心资源耗尽。

2.3 选型判断:Boot 4 默认开启虚拟线程背后的考量

Spring Boot 4 里虚拟线程已经变成了非常顺滑的配置项。和早期版本不同,Boot 4 对虚拟线程的支持做到了 Servlet 容器、WebFlux、JMS、@Async、@Scheduled 全覆盖,也就是说你不需要为不同模块单独做适配。

我自己的工程在 Tomcat 上跑,直接把spring.threads.virtual.enabled=true一开,Tomcat 就切换到虚拟线程执行请求。底层每个 HTTP 请求对应一个虚拟线程,不再复用 Tomcat 的传统工作线程池。对业务代码来说,项目本身的接口逻辑一行不用改,这迁移成本低到让人有点不适应。

不过有一点要明确:虚拟线程不是万能药。如果你的业务代码里大量使用 synchronized 块,或者调用了用 synchronized 实现的第三方库,虚拟线程会被钉在载体线程上(pin 住),导致载体线程被占用而无法释放,严重时反而比传统线程池更糟。所以选型时需要先扫描一下项目里有没有这类代码。

3. 虚拟线程 + Boot 4 的核心配置与原理验证

3.1 最小化配置:一行开关加上几个关键参数

先看最简单的配置,在application.yml里:

spring: threads: virtual: enabled: true

打开这个开关之后,Spring Boot 4 会自动用虚拟线程替换 Tomcat 的请求处理线程。如果是 WebFlux 场景,spring.threads.virtual.enabled=true也会让 Reactor Netty 的事件处理线程组变成虚拟线程执行。

如果你想自己控制虚拟线程的创建细节,比如前缀、优先级、是否守护,可以手动定义:

@Bean public VirtualThreadFactory virtualThreadFactory() { return Thread.ofVirtual() .name("biz-vt-", 0) .allowSetThreadLocals(true) .uncaughtExceptionHandler((thread, throwable) -> log.error("虚拟线程异常, thread={}", thread, throwable)) .factory(); }

这个工厂类在业务代码里通过Executors.newVirtualThreadPerTaskExecutor()搭配使用,可以随时创建一个全新的虚拟线程来跑任务,不需要池化。

3.2 验证虚拟线程是否真的生效

把开关开了不算完,最好自己验证一下。在任意一个 Controller 里打印线程信息:

@GetMapping("/thread/info") public Map<String, String> threadInfo() { Thread thread = Thread.currentThread(); return Map.of( "name", thread.getName(), "isVirtual", String.valueOf(thread.isVirtual()), "groupId", thread.getThreadGroup() == null ? "null" : thread.getThreadGroup().getName() ); }

实际跑起来,Thread.currentThread().isVirtual() 返回 true,线程名会带上类似http-nio-exec-1变体或你自定义的前缀。如果看到isVirtual=false,先查一下是否引入了老版本的 Tomcat 配置,比如自定义了server.tomcat.threads.max之类,这些配置在虚拟线程模式下优先级可能被覆盖。

3.3 关键参数说明:不要随意调大默认值

虚拟线程模式下,Tomcat 的server.tomcat.threads.max这个参数基本失效了。虚拟线程本身没有数量上限约束,所以 Boot 4 不再按这个参数来限制工作线程数。

需要留意的是server.tomcat.threads.max-keep-alive和server.tomcat.keep-alive-timeout这些连接层参数。虚拟线程解决的是 CPU 密集阻塞等待问题,但 TCP 连接本身还是受系统限制的。大量客户端长连接会占用文件描述符,建议提前调大操作系统的文件描述符上限。

另外一点,如果你在虚拟线程里使用了ThreadLocal,要注意内存回收。虚拟线程数量可以非常多,每个 ThreadLocal 都会留在虚拟线程的 context 上,如果线程不结束,ThreadLocal 里的对象就一直没有释放。所以 ThreadLocal 里的东西要尽量简单,用完手动 remove。

4. 秒杀场景实战:高并发写入与库存扣减的全链路方案

4.1 秒杀接口的整体链路设计

秒杀场景我按下面这条链路来设计:

  • 入口:Nginx 层限流,按用户维度做请求频率限制。
  • 应用层:使用虚拟线程异步处理请求,快速返回排队结果,不阻塞 HTTP 线程。
  • Redis 层:用 Lua 脚本完成库存扣减,保证原子性。
  • 数据库层:异步落订单,通过事务消息或本地消息表来保障最终一致性。

虚拟线程在这里的核心价值不是在极限 QPS 上比线程池高出多少,而是让"每个请求独立去抢 Redis 锁、去查询库存、去写订单"这种天然高并发模式不再需要共享一个固定线程池。每个请求就是一条独立的虚拟线程,互不干扰。

4.2 Lua 脚本扣减库存:为什么不用纯 Java 判断

先看库存扣减的 Redis Lua 脚本,这是秒杀链路里最关键的原子操作:

local key = KEYS[1] local stock = tonumber(redis.call('GET', key)) if not stock then return -2 end if stock <= 0 then return -1 end redis.call('DECR', key) return stock - 1

为什么用 Lua 脚本?因为 Redis 的 Lua 脚本是原子执行的,脚本执行期间不会有其他命令穿插,所以检查库存和扣减库存两个操作可以封装成一步。纯 Java 代码先 GET 再 DECR 会有竞态窗口,并发高时容易出现超卖。

实际测试下来,单个 Redis 节点的 Lua 扣减 QPS 大概到 3w+ 以后才会出现明显延迟,扣减本身不是瓶颈。瓶颈在后续的订单写入和缓存一致性处理上。

4.3 订单异步落库:虚拟线程如何撑住瞬时写峰

秒杀接口收到请求后,按我的实践,不要直接在请求线程里写数据库。秒杀请求的特点是"抢到资格的人和没抢到资格的人"比例悬殊,如果每个请求都去写订单表,数据库连接池瞬间就会被击穿。

我的做法是:抢到库存资格后,把订单数据丢到队列里,由后台异步任务消费并写入数据库。虚拟线程在这个环节的价值非常明显:异步消费线程不再有上限问题,你可以放心地把几十万条订单消息拆给多个虚拟线程去并发处理,不需要像传统线程池那样反复权衡该开多少个线程。

示例代码如下:

@Bean(name = "orderConsumeExecutor") public ExecutorService orderConsumeExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } // 消费队列 @Component public class OrderConsumer { @Resource private OrderMapper orderMapper; @SneakyThrows public void consume(OrderCreateMessage message) { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> { // 注意:这里的方法不要用 synchronized orderMapper.insert(buildOrder(message)); }); } } }

这里有一点比较容易被忽略:try-with-resources方式创建的 ExecutorService,在 try 块结束时不会等待任务执行完毕,而是会立即关闭并中断还在执行的任务。这个特性在新版 JDK 里是刻意的,但很多迁移老代码的人会踩坑。需要等待所有任务跑完时,要改成显式调用executor.close()并让任务自行结束,或者使用try (var executor = ...) { executor.submit(...); }后面加executor.awaitTermination()之类的控制。

4.4 防超卖和防重:数据库层的最后一道防线

Redis 扣减成功不代表数据库层一定安全。原因很简单:如果订单落库后用户没有支付,这笔库存是要回补的。回补时再去操作 Redis 容易产生数据不一致。

我的方案是在数据库层做了唯一键约束。订单表里用"用户ID + 秒杀场次ID + 商品SKU"作为唯一键,防止同一个用户重复下单。落库时如果捕获到主键冲突异常,就直接判断为重复请求。这个并不依赖虚拟线程,但在整体方案里属于必备防线。

库存回补则通过定时任务扫描超时未支付的订单,回补 Redis 库存。这里同样用虚拟线程来并发扫描多张分表,加速回补过程。

4.5 秒杀场景压测数据对比

我拿了一个 8C16G 的实例做对比测试,接口逻辑相同,唯一区别是线程模型。

指标传统固定线程池(200线程)虚拟线程
峰值 QPS约 12000约 15000
P99 延迟420ms310ms
线程数最大 200数千至万级
CPU 使用率峰值82%75%
上下文切换次数/秒约 8 万约 2 万

这个结果很有代表性:虚拟线程并没有把单核性能榨到极限,但因为阻塞时让出载体线程的效率更高,整体吞吐和延迟都变好了。上下文切换次数大幅下降是最直接的收益。

5. 批量处理场景实战:海量任务吞吐优化与进度控制

5.1 批量处理的需求拆解:为什么传统线程池不够用

批量处理与秒杀不同,单个任务耗时更长。我做过一个真实需求:从上游拉取一批数据,逐条调用外部服务校验并补充字段,然后写回数据库。数据量在 20 万到 60 万条之间,单条处理耗时约 200ms 到 2s 不等。

如果用固定线程池,比如 32 个线程,那么平均 300ms 一条的处理速度下,一分钟能处理 60 秒 / 0.3 秒 * 32 ≈ 6400 条。20 万条数据大概需要 30 分钟以上。

如果中途某条数据卡住了,线程池里的线程被占住,整体吞吐会大幅下降。传统做法是给单条处理加超时控制,比如 Future.get(timeout),但超时后只是标记失败,资源并没有释放。

虚拟线程的思路完全不同:每个任务就是一条虚拟线程,阻塞时自动释放载体线程。单个任务卡住不会拖垮其他任务,你甚至可以为不同批次任务动态创建不同数量的虚拟线程而不必担心资源耗尽。

5.2 分片加工与虚拟线程批量执行核心代码

我用的是"主线程控制 + 虚拟线程批量执行 + 分片提交"的模式。主线程负责任务拆分和进度汇总,虚拟线程负责真正执行单条数据的处理逻辑。

代码结构大体如下:

@Component public class BatchProcessService { private final DataFetcher dataFetcher; private final ExternalValidator externalValidator; private final DataWriter dataWriter; public BatchResult process(List<Long> ids, int batchSize) { int total = ids.size(); int batchCount = (total + batchSize - 1) / batchSize; AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); List<Throwable> errors = Collections.synchronizedList(new ArrayList<>()); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<?>> futures = new ArrayList<>(); for (int i = 0; i < batchCount; i++) { int from = i * batchSize; int to = Math.min(from + batchSize, total); List<Long> batchIds = ids.subList(from, to); futures.add(executor.submit(() -> processBatch(batchIds, successCount, failCount, errors))); } // 等待所有任务执行完 for (Future<?> future : futures) { try { future.get(); } catch (ExecutionException e) { log.error("批次处理失败", e.getCause()); } } } return new BatchResult(successCount.get(), failCount.get(), errors); } private void processBatch(List<Long> batchIds, AtomicInteger success, AtomicInteger fail, List<Throwable> errors) { for (Long id : batchIds) { try { Object data = dataFetcher.fetch(id); Object enriched = externalValidator.validateAndEnrich(data); dataWriter.write(enriched); success.incrementAndGet(); } catch (Exception e) { fail.incrementAndGet(); errors.add(e); } } } }

关键点在于try (var executor = ...)配合future.get()的写法。close 调用会阻塞直到所有提交任务执行完,这就是前面说的"需要显式等待时"的正确用法。我实测过,在虚拟线程模式下,上面这个循环即使给每个执行体提交 10000 个任务,也不会报RejectedExecutionException,因为虚拟线程没有池容量上限。

5.3 进度控制与动态调速:虚拟线程带来的新玩法

批量处理的另一个痛点是进度控制。固定线程池模式下,你没法在任务运行中动态调整并发度,只能跑完或者重启。虚拟线程模式下,因为线程创建成本低到可以忽略,你可以在批处理的前半段用 1000 个虚拟线程跑,中途发现数据库压力大,及时把并发度降下来。

具体做法是在循环加一个令牌桶或者信号量来控制同时执行的虚拟线程数量:

Semaphore semaphore = new Semaphore(500); IntStream.range(0, taskCount).forEach(i -> { executor.submit(() -> { semaphore.acquire(); try { processOne(i); } finally { semaphore.release(); } }); });

这样虚拟线程本身想开多少就开多少,但真正在"同时干活"的任务数由信号量限制。上游数据库压力大时,把信号量的许可数调小,压力小时调大。不用重启、不用停任务,热更新配置即可。

5.4 批量场景的实际吞吐对比

同样处理 20 万条数据,单条处理耗时平均约 400ms,我的对比数据如下:

方案线程数总耗时数据库连接池占用
固定线程池 1616约 83 分钟低
固定线程池 6464约 21 分钟中
虚拟线程 + 信号量 500实际占用约 300约 4 分钟可控

注意虚拟线程场景下,虽然并发任务数可以到几千,但数据库连接池通常只有几十个连接。如果每个虚拟线程里都去获取数据库连接,连接池会排队。所以我把信号量初始值设置在连接池大小之内,保证不会因为数据库连接不足而相互等待死锁。

6. 全链路高并发的工程化落地:监控、隔离与容灾

6.1 线程隔离:为什么不能用虚拟线程直接替换所有线程池

虚拟线程虽好,但工程上还是要做隔离。一个重要场景:秒杀接口和报表导出接口共用服务器。报表导出是 CPU 密集型任务,如果秒杀和导出都在同一批虚拟线程里跑,导出任务会占用大量 CPU,秒杀接口延迟会明显变差。

我做了三层隔离:

  • 第一层:物理隔离。秒杀服务和批量导出服务部署在不同实例上。
  • 第二层:线程隔离。即便在同一实例内,也分别为"高优先级短事务"和"低优先级长任务"创建独立的虚拟线程执行器。虚拟线程创建成本低,所以你可以随意创建几十个执行器互不干扰。
  • 第三层:舱壁隔离。每个业务域使用独立的数据库连接池、独立的 Redis 连接池,防止一个域的资源耗尽拖垮另一个域。

6.2 监控与告警:虚拟线程运行时必须盯住的关键指标

虚拟线程模式下,传统的线程池监控指标比如ActiveCount、PoolSize都不再适用了。我建议重点监控以下指标:

指标监控方式告警阈值参考
载体线程数JFR 里的 Carrier Threads持续高占用需关注
虚拟线程总数ThreadMXBean的线程数超过预期 5 倍告警
阻塞事件耗时JFR 的 Blocking Events单次超过 1s 告警
数据库连接等待连接池的 Active/Available等待超 500ms 告警
CPU 使用率系统级监控持续超 85% 告警

比较实用的监控手段是开 JFR。JFR 里可以直接看到虚拟线程的ForkJoinPool载体线程占用情况,以及每个虚拟线程阻塞在哪个资源上。我在定位一次批量处理卡顿时,靠 JFR 一眼看出是外部接口的连接池配置过小,而不是虚拟线程本身的问题。

6.3 容灾与幂等设计:高并发系统的最后一道保命符

不管并发方案多完美,最终系统还是要保证数据不出错。虚拟线程环境下面临的幂等挑战是:任务被重复执行的概率变高了,因为虚拟线程生命周期短,异常退出后重新入队的虚拟线程可能再次执行同一任务。

我建议所有批量处理任务都要做到幂等:

  • 每条任务带唯一 ID,处理前先检查"是否已处理"标记。
  • 写库时使用唯一键约束,重复插入直接失败。
  • 状态流转用乐观锁版本号,防止并发更新覆盖。

秒杀场景也一样,用户发起重复请求时,靠唯一键约束和 Redis 的原子扣减来挡掉重复订单。

7. 常见问题与排查实录:虚拟线程实战中踩过的坑

7.1 虚拟线程被 synchronized 钉住导致吞吐下降

这是我第一次上生产时踩到的坑。老项目里有个工具类用了 synchronized 方法,虚拟线程切换后,高峰期出现了诡异的现象:线程数上去了,CPU 不高,但接口延迟变得无法接受。

排查过程:先看 JFR 的线程阻塞记录,发现大量载体线程处于 moniter 等待状态,也就是虚拟线程在 synchronized 块里被钉在载体线程上。因为 synchronized 的监视器锁由载体线程持有,所以虚拟线程阻塞时不能挂起,必须占用载体线程继续等待。

解决办法有两种:

  • 把 synchronized 换成ReentrantLock,锁可以在阻塞时真正释放载体线程。
  • 如果第三方库改不了,就把这些对象单独交给一个固定线程池处理,不要在虚拟线程里调用。

7.2 ThreadLocal 泄漏导致内存飙升

虚拟线程数量多,ThreadLocal 被滥用时内存问题会比传统线程池严重得多。一次批处理任务里,我往 ThreadLocal 塞了一个很大的上下文对象,理论上任务结束后线程会销毁,ThreadLocal 应该随之回收。但虚拟线程的存活时间恰好较长,累积了几万个 ThreadLocal 后,内存占用直接飙升了几个 GB。

排查思路:JFR 的内存泄漏分析里可以看到大量 ThreadLocalMap 存活。定位到代码后,我在 finally 块里显式调用remove()。虚拟线程环境下,ThreadLocal 一定要慎用,每一条都最好在 finally 里清掉。

7.3 数据库连接池默认参数与虚拟线程不匹配

虚拟线程放大了并发请求数,如果数据库连接池还是默认的最小池配置,大概率会出现连接等待超时。我的处理是:把连接池的最小连接数调大到常规值的 1.5 ~ 2 倍,并设置连接获取超时时间。这一步是高并发虚拟线程场景的必经优化,不做的话连接池会成为新的瓶颈。

7.4 快速失败:虚拟线程不适合长时间霸占载体线程的任务

最后提醒一点:虚拟线程并不适合那种"纯 CPU 计算且不阻塞"的任务,例如大量数据排序、复杂加密计算。因为这种任务不会主动让出载体线程,开再多的虚拟线程也只是排队等有限的 CPU 核心。这种场景下,关键优化是分治和降级,而不是加线程。

8. 最后再分享两个实测下来的细节

第一个细节是 JDK 版本。建议直接上 21 LTS 以上,不要用 17 提前体验版本。虚拟线程在 21 版本里已经很强了,很多早期版本遗留的表达式性能和调试问题都修掉了。如果还在用 17,Boot 4 的某些自动配置可能需要在编译参数上做额外处理,浪费精力。

第二个细节是连接池参数。虚拟线程环境下,连接池的maximumPoolSize建议至少调成原固定线程池的两倍,但不要无脑调太大,调太大会让数据库压力激增。我自己的实践是先用信号量限制真正并发数,再让连接池只保留实际需要的连接数,两者配合使用。

虚拟线程给人的感觉很像当年的泛型:刚出现时很多人觉得没必要,等真正用上手之后,就很难回到老写法了。不必神话它,该用线程池的场景还是要用线程池,但高并发业务里,它确实值得你花一个迭代周期去认识和研究。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 16:50:03

基于CNN的昆虫类别识别:从数据准备到训练调参的完整实战指南

每年到了毕业设计季&#xff0c;总能看到不少同学在“图像识别”这个方向里打转。尤其是“基于CNN的昆虫类别识别”这类题目&#xff0c;几乎是深度学习毕设里最经典、也最稳的一种选题。它不像目标检测那样需要处理复杂的边界框回归&#xff0c;也不像语义分割那样涉及逐像素的…

作者头像 李华
网站建设 2026/10/17 16:49:25

地府管理系统.zip完整拆解:部署、权限与复用指南

简介&#xff1a;地府管理系统源码资源包是一套以模拟中国传统文化阴间运作流程为核心的管理系统项目&#xff0c;面向希望练习 Vue 前端、JavaScript 逻辑与数据库设计的开发者和学生&#xff0c;可服务于课程设计、毕业设计、个人练手或业务原型搭建。资源共 131 个文件&…

作者头像 李华
网站建设 2026/10/17 16:49:18

数据链路层介质访问控制实战:ALOHA/CSMA/CD/CA深度解析

1. 这不是教科书里的“背诵清单”&#xff0c;而是数据链路层介质访问控制的实战解剖现场你有没有遇到过这样的场景&#xff1a;实验室里十几台开发板同时往一个共享无线模块发指令&#xff0c;结果指令像春运火车站广播一样互相盖过&#xff0c;设备要么收不到&#xff0c;要么…

作者头像 李华
网站建设 2026/10/17 16:48:40

因子图与和积算法MATLAB源码:消息传递破解概率推理指数爆炸

简介&#xff1a;围绕因子图与和积算法的 Matlab 源码包&#xff0c;面向通信、机器学习与贝叶斯推断方向的学生和研究人员&#xff0c;适合课程设计、科研复现与算法自学。资源基于 SumProductLab 设计&#xff0c;覆盖消息传递算法&#xff08;MPA&#xff09;、LDPC 编解码、…

作者头像 李华
网站建设 2026/10/17 16:46:42

Maven插件机制深度解析:构建生命周期绑定与生产级配置实践

1. 这不是配置文件&#xff0c;而是一份“构建契约”——从pom.xml的plugin说起你打开一个Java项目的pom.xml&#xff0c;扫到<build><plugins>那一块&#xff0c;大概率会下意识跳过——毕竟它不像<dependencies>那样直接决定“用什么库”&#xff0c;也不像…

作者头像 李华
网站建设 2026/10/17 16:45:52

Weka KnowledgeFlow:从Explorer到可视化流程的进阶实践

Weka系列写到第五篇&#xff0c;前四篇我们一直在Explorer里打转。Explorer是真好用&#xff0c;选数据、选算法、点一下Start就出结果&#xff0c;但用上几轮你一定会遇到两个问题&#xff1a;一是这些操作过程没有地方保存&#xff0c;想复现某次实验结果只能靠截图、靠笔记、…

作者头像 李华