主题
界面
主题
《Nginx 负载均衡与缓存》讲网关;本节讲**"高性能网络编程的底座"**:
BIO:一个连接一个线程 → 简单,但线程数随连接数线性增长 ✗
NIO:事件驱动(Selector)→ 少数线程撑大量连接 ✓✓
Netty:把 NIO 的复杂度封装掉 → 你还写业务,剩下的交给它 ✓✓✓★ 本节的实测会纠正一个常见误解 ✓✓:
BIO:100 客户端 × 20 消息 → 689 ms,服务端线程数峰值 **179** ✗✗
NIO:100 客户端 × 20 消息 → 655 ms,处理 100 个连接只用 **1 个 reactor 线程** ✓✓★ 性能几乎一样(689 vs 655 ms)——差别在"用了多少资源" ✓✓
先看一个容量估算 ✓:
BIO 服务:1 个连接 = 1 个线程
· 1 万连接 → 1 万线程 ✗✗
· 每线程栈默认 512KB~1MB → **5~10 GB 内存** ✗✗
· 更致命:这么多线程抢 CPU,**上下文切换**会吃掉大部分算力([S2-009](../../02-cs/os/S2-009))✗★ 而本节的实测给出了 NIO 的答案 ✓✓:
同样的 100 个连接、同样的消息量:BIO 用到 179 个线程,NIO 只用到 1 个 reactor 线程 ✓✓
进程总线程数 8(基线 7,几乎没涨) ✓
实测数据 ✓✓:
| 模型 | 100 客户端 × 20 消息 | 服务端线程数 |
|---|---|---|
| BIO(一连接一线程) | 689 ms | 179 个 ✗✗ |
| NIO(Selector 单线程) | 655 ms | 1 个 reactor + 基线 ✓✓ |
★★ 两条结论必须一起看 ✓✓:
| 结论 | 说明 |
|---|---|
| 两者在本机 100 连接下性能相当 ✓ | BIO 并不"慢" ——它在连接数不高时完全够用,且代码简单得多 ✓✓ |
| 但资源占用差两个数量级 ✓✓ | NIO 的价值是"用常数级线程撑大量连接" ✓ |
★ 所以选型是这样的 ✓✓:
| 场景 | 选择 |
|---|---|
| 连接数几百以内 ✓ | BIO 完全够用(如内部服务、定时任务、连接数可控的客户端)✓ |
| 连接数上万(IM、网关、推送) ★★★ | 必须 NIO(BIO 的线程数会直接压垮机器)✗✗ |
| 要自定义协议 ★★ | Netty(NIO 的复杂度不值得手写)✓✓ |
本节的 NIO 服务端骨架(约 60 行) ✓:
Selector selector = Selector.open();
serverChannel.configureBlocking(false); // ① 非阻塞是前提
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // ② 阻塞等"有事件发生"
for (SelectionKey key : selector.selectedKeys()) {
if (key.isAcceptable()) { ... register(OP_READ); } // ③ 分发:连接事件
else if (key.isReadable()) { channel.read(buffer); } // ③ 分发:读事件
}
}★ 三步就是 Reactor 的全部 ✓✓:注册感兴趣的事件 → 等待事件 → 分发处理 ✓
★★ 但这 60 行"只是能跑" ✗ ——离生产还差很远 ✓:
| 还缺什么 | 不处理的后果 |
|---|---|
| 写半包处理 ★★ | write 不保证写完,剩余数据会丢 ✗ |
| 空轮询 bug 处理 ★★ | JDK 的 select() 可能无事件返回,导致 CPU 100% ✗✗ |
| 多线程 Reactor 模型 ★ | 单线程处理所有 IO,CPU 用不满 ⚠️ |
| 粘包拆包 ★★★ | 读到的数据边界不确定 ✗✗ |
| 连接空闲检测 ★★ | 死连接一直占着资源 ✗ |
| 优雅关闭 ★★ | 关闭时丢消息 ✗ |
| 内存池 ★ | 频繁分配 ByteBuffer → GC 压力 ⚠️ |
| 流量整形 | 突发流量打垮后端 ⚠️ |
★★ 这 8 条就是 Netty 存在的理由 ✓✓
实测(服务端发 3 条带长度前缀的消息)✓:
① 朴素读法(一次 read 当一条消息):把 3 条消息当成了 1 条 ✗✗
② 按长度字段解码(正确的做法):解出 3 条:[订单-0, 订单-1, 订单-2] ✓✓★ 这是 《TCP 与 UDP》 的结论在 NIO 里的落地 ✓✓:
TCP 是字节流,消息边界必须由协议自己定义(定长 / 分隔符 / 长度字段)✓✓
★★ 而 NIO 里这件事更棘手 ✓:
一次
read读到哪里完全不确定 ——可能读到半条、也可能读到三条 ✗所以需要「半包缓存 + 状态机」:读不满就存着,下次接着解 ✓
★ Netty 的答案是一行代码 ✓✓:
pipeline.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4));★ 它内部就是"半包缓存 + 状态机",而且处理了各种边界情况 ✓ —— 你自己写这 100 行,大概率会有漏掉的分支 ✗
★ 本节的 NIO 代码 vs Netty 的写法 ✓:
| 本类的 NIO 写法 | Netty 对应 |
|---|---|
ServerSocketChannel + bind | ServerBootstrap.bind(port) |
register(OP_ACCEPT) | NioEventLoopGroup(boss 组) |
while(true) + selector.select() | EventLoop 内部循环(不用你写) ✓ |
遍历 selectedKeys | pipeline 上的 handler 回调 ✓ |
| 手写长度字段解码 | LengthFieldBasedFrameDecoder(一行) ✓✓ |
| 手写空闲检测 | IdleStateHandler ✓ |
| 手写优雅关闭 | shutdownGracefully() ✓ |
★ 一份典型 Netty 服务端骨架 ✓✓:
EventLoopGroup boss = new NioEventLoopGroup(1); // 接连接
EventLoopGroup worker = new NioEventLoopGroup(); // 处理 IO
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)) // 拆包
.addLast(new StringDecoder())
.addLast(new StringEncoder())
.addLast(new MyBusinessHandler()); // ★ 你只写业务
}
});
bootstrap.bind(18091).sync();★★ 关键认识 ✓✓:
Netty 的价值不是"更快",而是"把 NIO 的复杂度封装掉" ✓✓
· 它仍然用 NIO(或 epoll 原生传输),性能上限与手写 NIO 相当 ✓
· 但它把「半包、空轮询、内存泄漏、优雅关闭」这些坑都填了 ✓✓
★ 这也解释了它的地位 ✓:
Dubbo、RocketMQ、gRPC-Java、Elasticsearch 的底层都是 Netty ✓✓
★ 三种情况 ✓:
| 场景 | 更好的选择 |
|---|---|
| 只是客户端发 HTTP 请求 ★★ | HttpClient(《HTTP 与 HTTPS》)✓ |
| 只是写个内部服务 ★★ | Spring Boot 内嵌 Tomcat(《Spring Boot 自动配置与项目结构》)✓ |
| 连接数不高 ✓ | BIO 就够了(本节实测:性能相当,代码更简单)✓✓ |
★ 真正需要 Netty 的信号 ✓✓:
① 要自己定义协议(IM、游戏、私有 RPC)✓
② 连接数极高(数万以上长连接)✓✓
③ 要精细控制网络层(拆包、心跳、流量整形)✓★ 学习路径建议 ✓✓:
先按本节写一遍 NIO 的 echo,再去看 Netty 的 API ✓
否则看 Netty 只会记住一堆类名,不知道每个类在解决什么问题 ✗✓
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept();
new Thread(() -> handle(client)).start(); // ✗ 连接数 = 线程数
}见 5.2 的骨架 ✓
// ✗ 在 reactor 线程里查数据库(阻塞 10 ms)
// → 这 10 ms 内所有连接的事件都得不到处理 ✗✗
if (key.isReadable()) {
User user = userDao.find(id); // ✗✗ 严禁在 IO 线程做阻塞操作
}★ 正确做法:业务处理丢到独立的业务线程池 ✓✓ —— 这就是 Netty 的 addLast(businessGroup, handler) 的意义 ✓
代码:labs/distributed-lab/src/main/java/cn/kb/learn/dist/netty/NioLesson.java
运行:
cd labs
.\mvnw.cmd -pl distributed-lab compile
java "-Dstdout.encoding=UTF-8" -cp distributed-lab/target/classes cn.kb.learn.dist.netty.NioLesson实测输出(关键部分):
===== 1. BIO =====
100 个客户端 × 20 条消息: 689 ms
服务端线程数峰值:179(基线 7,即为每个连接起了 1 个线程)✗✗
===== 2. NIO =====
100 个客户端 × 20 条消息: 655 ms
服务端处理连接数:100,而只用 1 个 reactor 线程 ✓✓
进程总线程数:8(基线 7,几乎没涨)✓✓
===== 3. 粘包处理 =====
① 朴素读法:把 3 条消息当成了 1 条 ✗✗
② 按长度字段解码:解出 3 条:[订单-0, 订单-1, 订单-2] ✓✓★ 三条可落地的结论 ✓:
| # | 结论 | 依据 |
|---|---|---|
| ① | NIO 的收益是扩展性,不是延迟 | 性能相当,线程数 179 vs 1 ✓✓ |
| ② | NIO 的复杂度必须被封装(否则坑极多) | 8 项缺失 ✓ |
| ③ | 业务逻辑绝不能跑在 IO 线程上 | 会阻塞所有连接 ✓✓ |
isReadable 分支里加 Thread.sleep(10):观察吞吐断崖 ✓✓;jstack 看 BIO 服务端的线程栈:观察上百个同构的 worker 线程 ✓。| # | 错误 | 说明 |
|---|---|---|
| 1 | 以为 NIO 一定比 BIO 快 ✗✗ | 是扩展性差异,不是速度 ✓✓ |
| 2 | 在 IO 线程里做阻塞操作 ✗✗ | 会卡住所有连接 ✓✓ |
| 3 | 手写 NIO 却不管写半包 ✗✗ | 数据会丢 ✓ |
| 4 | 不管空轮询 ✗✗ | CPU 飙到 100% ✓ |
| 5 | 连接数不高也要上 Netty ✗ | 过度设计 ✓ |
| 6 | 以为"用了 Netty 就快" ✗ | 业务代码里阻塞照样慢 ✓ |
| 7 | 不理解粘包就上线自定义协议 ✗✗ | 偶发数据错乱,极难排查 ✓✓ |
目标:亲眼看到"线程数"这条曲线。
步骤:
NioLesson,记录 BIO 与 NIO 的线程数 ✓;验收方式:能说出"NIO 与 BIO 的差别在哪、什么时候该用哪个"。
扩展任务:用 Netty 实现一个心跳检测:
IdleStateHandler 实现 ✓;if (key.isReadable()) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);
String message = new String(buffer.array());
handle(message);
}这段代码有什么问题? 5. (设计)给"IM 消息推送"选型:10 万长连接,消息要实时推。用什么?为什么?
select());分发处理 ✓read 返回值被忽略)✗;② 没处理粘包/半包 ✗✗; ③ buffer.array() 可能含上次残留 ✗(应按 flip() 后的 limit 取值)✓IdleStateHandler 做心跳、LengthField 拆包、业务线程池隔离 ✓✓《Dubbo 与 RPC 基础》:把方法调用变成网络请求,以及它贵在哪。
暂无