Linux网络(02):Socket 编程 UDP

💬 :如果你在阅读过程中有任何疑问或想要进一步探讨的内容,欢迎在评论区畅所欲言!我们一起学习、共同成长~!

👍 :如果你觉得这篇文章还不错,不妨顺手点个赞、加入收藏,并分享给更多的朋友噢~!


引言 :《Linux网络(01):网络基础概念》我们建立了网络协议栈的分层认知,明确了传输层的定位与核心职责,本章正式进入传输层编程实战。

  • 传输层最典型的两种协议就是TCP(Transmission Control Protocol,传输控制协议)和UDP(User Datagram Protocol,用户数据报协议)。
  • Socket 即"套接字",是操作系统暴露给应用层的核心网络编程接口 ------ 所有 TCP、UDP 网络编程,本质都是围绕 "套接字" 系统调用展开。

1. UDP 编程预备知识

1.1 TCP 与 UDP 对比【必考核心】

考察形式:笔试选择 / 简答、一面基础问答、二面场景选型追问。这是网络方向出现频率最高的送分题,必须背到张口就来。

维度 TCP UDP
连接 面向连接(三次握手) 无连接,直接发
可靠性 可靠 :确认、重传、去重、有序 不可靠 ,尽力而为:可能丢包、无序、重复,协议自身不感知
数据形态 面向字节流,无消息边界 ➡ 粘包 / 拆包 面向报文,保留消息边界
通信模式 点对点(一条连接两个端点) 一对一 / 一对多 / 多对多(支持广播、组播)
流量 / 拥塞控制 有 无
首部开销 20 字节起 8 字节
典型场景 HTTP/HTTPS、SSH、FTP、SMTP、数据库、文件传输 DNS、DHCP、TFTP、音视频直播、在线游戏状态同步、实时语音、QUIC

💡 一句话总结:TCP 面向连接、可靠、有序、字节流;UDP 无连接、不可靠、无序、报文。

1.2 UDP 与套接字核心函数总览【必考核心】

  • 套接字系统调用头文件:#include <sys/socket.h>
API 作用 UDP 用法
socket(domain, type, protocol) 创建套接字 AF_INET, SOCK_DGRAM, 0
bind(fd, addr, len) 绑定 IP + 端口 服务端必须显式调用
recvfrom(fd, buf, len, 0, &peer, &addrlen) 收报文 + 取对端地址 服务端 / 客户端都用
sendto(fd, buf, len, 0, &dest, len) 发报文到指定地址 每次都要带目的地址
listen/accept/connect TCP 专属 ❌ UDP 不用

1.3 sockaddr 通用结构体【必考核心】

考察形式:笔试选择(结构体字段顺序"地址族→端口→IP→填充字段";sin_zero 作用"sin_zero 是 sockaddr_in 的填充字段,作用是把结构体总长度补齐到和通用 sockaddr 一致")、代码补全题(强转写法)、面试简答(为什么要设计通用 sockaddr)。

为什么要设计 sockaddr ?

套接字既支持跨网络的进程间通信,也支持本地域的进程间通信。socket 这套 API 要同时支持多种地址类型:IPv4 网络套接字(sockaddr_in 结构体)、Unix 域本地套接字(sockaddr_un 结构体)、IPv6 套接字(sockaddr_in6 结构体)等。 如果每种地址都单独写一套接口,接口会非常冗余,为了让套接字的网络通信和本地通信能使用同一套接口,设计了通用结构体 sockaddr。

  • 所有具体地址结构体的开头 2 字节(16 比特位)都是地址族字段,内存位置、大小完全对齐;

  • 所有具体地址结构体的总大小都补齐到和 struct sockaddr 一致; 这样所有 socket API 都只需要接收 struct sockaddr* 这一种指针类型,内核内部读取第一个字段的地址族值,就能判断出实际是哪种地址结构体,再按对应结构体格式解析后续数据。

我们看一下结构体内核源码(不需要记忆)来帮助理解:

struct sockaddr

cpp 复制代码
struct sockaddr {
    __SOCKADDR_COMMON(sa_); // 2字节:地址类型标记
    // 14字节:只是一块占位内存,不同地址类型会往此区域填充不同内容
    char sa_data[14];       
};

1.3.1IPv4 专用:struct sockaddr_in

cpp 复制代码
struct sockaddr_in {
    __SOCKADDR_COMMON(sin_); // sin_family,和sockaddr的sa_family位置、大小完全对齐
    in_port_t sin_port;      // 2字节:端口号
    struct in_addr sin_addr; // 4字节:IPv4 地址
    // 【sin_zero作用】:把结构体总长度补齐到和struct sockaddr一致
    unsigned char sin_zero[sizeof(struct sockaddr) - __SOCKADDR_COMMON_SIZE
                          - sizeof(in_port_t) - sizeof(struct in_addr)];
};

1.3.1 代码实际写法

我们实际使用 struct sockaddr 结构体时,① 定义 struct sockaddr_in 结构体,② 调用系统调用传参时强转 (struct sockaddr*)&addr,示例中即 (struct sockaddr*)&local。

代码示例(对应 Echo 服务端 bind 片段):

cpp 复制代码
#include <netinet/in.h> // 网络地址结构体头文件,提供 sockaddr_in、端口/IP类型定义

// 1. 我们自己定义、填充的是具体的 sockaddr_in 结构体
struct sockaddr_in local;
bzero(&local, sizeof(local));      // 先清空整个结构体
local.sin_family = AF_INET;       // 标记地址类型为 IPv4
local.sin_port = htons(8888);    // 填充端口(转网络字节序)
local.sin_addr.s_addr = INADDR_ANY; // 填充 IP

// 2. 调用 bind 系统调用时,强转为通用 sockaddr* 指针传入
// 函数原型: int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
bind(sockfd, (struct sockaddr*)&local, sizeof(local));

⚠️ 笔试坑点:

  1. ❗socket 地址结构体填充前强烈推荐用 memset 或 bzero 清零 ------ struct sockaddr_in local 是栈上局部变量(默认是脏数据),清零防止脏数据残留。
  2. sockaddr_in 三要素 sin_family / sin_port / sin_addr 必须填充,尤其是 sin_family 不填的话,内核识别不出地址类型,直接绑定失败。
  3. sin_port 填充端口号必须 htons 转网络字节序。
  4. sin_addr 填充 IP 推荐用 INADDR_ANY(详见 2.5)。
  5. bind 第三个参数应传实际结构体的长度 sizeof(sockaddr_in),即 sizeof(local)。

1.4 网络字节序与地址转换【必考核心】

📌联动 :依托前文《Linux网络(01):网络基础概念》5.5 网络字节序。编程中只有两处需要手动转换:端口号、IP 地址。

  • 端口号(16 位):主机字节序 → 网络字节序写入用 htons,网络字节序 → 主机字节序读出用 ntohs;
  • IP 地址(32 位):若是整数纯字节序转换,主机字节序 → 网络字节序写入用 htonl,网络字节序 → 主机字节序读出用 ntohl;若是点分十进制字符串 ↔ 网络字节序整数,交给地址转换函数。

1.4.1 地址转换函数【必考核心】

考察形式:笔试选择 / 代码输出题、一面问答(写 socket 代码时必用,必须默写)。

函数 作用 特点 推荐度
inet_addr 点分十进制字符串(如"127.0.0.1") → 网络字节序整数 失败返回 INADDR_NONE,无法区分合法地址 255.255.255.255 ❌ 已废弃
inet_aton 点分十进制字符串 → 网络字节序整数 返回 1/0 明确成败 可用
inet_ntoa 网络字节序整数 → 点分十进制字符串 返回静态缓冲区指针,非线程安全 ⚠️ 单线程可用
inet_pton / inet_ntop ⭐inet_pton:点分十进制字符串 → 网络字节序整数 ⭐inet_ntop:网络字节序整数 → 点分十进制字符串 支持 IPv4/IPv6,调用者提供缓冲区,线程安全 ⭐✅ 推荐

1.4.2 inet_ntoa 的静态存储区与线程安全【高频易错】

考察形式:笔试选择(判断对错)、二面追问(多线程场景)。

  • inet_ntoa 把结果放在内部静态缓冲区并返回指针,不需要调用者释放;

  • ⚠️ 坑点:函数内部共用同一个静态缓冲区,多次调用会覆盖上一次的结果,多线程并发调用时结果不可控。

  • 💡 对策:调用 inet_ntoa 后要立刻把字符串拷贝到自己的变量里,多线程环境优先用 inet_ntop。


2. V1 版本:Echo 回显服务器【实操主线】

掌握了核心概念与 API 全景,我们从最简单的 Echo 回显服务器入手,完整走通 UDP 服务端与客户端的编码、编译、运行全流程。

2.1 socket:创建套接字【必考核心】

cpp 复制代码
int socket(int domain, int type, int protocol);
// domain:   AF_INET(IPv4) / AF_INET6(IPv6) / AF_UNIX(本地域)
// type:     SOCK_DGRAM(UDP 数据报) / SOCK_STREAM(TCP 流)
// protocol: 填 0 表示按前两个参数自动推导协议
// 返回: 成功返回文件描述符(最小未用fd, 通常为3, 因0/1/2是标准输入/输出/错误);
//       失败返回 -1 并置 errno

2.2 bind:绑定 IP 与端口【必考核心】

cpp 复制代码
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
//   返回 0 成功;返回-1失败并置errno
//   bind失败最常见原因及errno的错误码为:
//   EADDRINUSE  → 端口已被其他进程占用
//   EADDRNOTAVAIL → 绑定的IP不在本机网卡上(典型: 云服务器bind公网IP)

⚠️ 坑点 详见 1.3.1

2.3 服务端事件循环:recvfrom + sendto【必考核心】

绑定完成后,服务端就可以进入核心事件循环,通过 recvfrom 接收客户端报文、通过 sendto 原样回显给客户端。

cpp 复制代码
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags,
                 struct sockaddr* src_addr, socklen_t* addrlen);
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags,
               const struct sockaddr* dest_addr, socklen_t addrlen);
// src_addr: 出参, 返回对端的IP+端口, UDP 无连接所以必须靠它识别数据来源
// addrlen:  输入输出型参数! 调用前必须初始化为结构体大小, 返回后为实际长度
// 返回: 成功返回实际接收字节数; 失败 -1 置 errno
参数 ① recvfrom(接收报文) ② sendto(发送报文)
int sockfd 套接字文件描述符 套接字文件描述符
① void* buf ② const void* buf 接收数据的缓冲区首地址(出参) 待发送数据的缓冲区首地址(入参)
size_t len 最大接收字节数 待发送数据的字节长度
int flags 接收标志,常规填 0 发送标志,常规填 0
struct sockaddr* 地址 ⭐src_addr:出参,返回对端 IP + 端口,UDP 无连接所以靠它识别数据来源 dest_addr:入参,指定目的 IP + 端口,要发给谁就填谁的地址
① socklen_t* addrlen ② socklen_t addrlen ⭐指针传递(入出型参数),调用前必须初始化为结构体大小,返回后为实际地址长度 值传递直接传入结构体大小
返回值 ssize_t ⭐成功返回实际接收字节数;失败返回 -1 并置 errno 成功返回实际发送字节数;失败返回 -1 并置 errno

📌结合 1.1:UDP "面向报文 ,保留消息边界",意味着一次 sendto 对应一次 recvfrom,一次 recvfrom 恰好取走一个完整报文。接收缓冲区小于实际到达的 UDP 报文长度时,超出部分被内核直接丢弃(所以有大报文截断丢数据问题,即截断式丢包)。

Echo 服务端事件循环

cpp 复制代码
char buffer[1024];
for (;;) { // 服务端死循环持续处理
    struct sockaddr_in peer;        // 出参: 保存客户端的IP+端口
    socklen_t len = sizeof(peer);   // 输入输出型参数调用前必须初始化为结构体大小
    // 接收客户端报文
    ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
    if (n < 0) { std::cerr << "recvfrom error\n"; continue; }
    buffer[n] = '\0';      // 手动补字符串结束符

    // 转回主机字节序打印对端信息
    std::cout << inet_ntoa(peer.sin_addr) << ":" << ntohs(peer.sin_port) << "# " << buffer << std::endl;

    // 按原地址回显给客户端
    sendto(sockfd, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
}

Echo 服务端收发循环

cpp 复制代码
std::string msg;
while (std::getline(std::cin, msg)) { // 整行读输入
    // 发服务端:首次sendto时内核自动分配临时端口并隐式bind
    sendto(sockfd, msg.c_str(), msg.size(), 0, 
           (struct sockaddr*)&server, sizeof(server));
    // 收服务端回显
    char buffer[1024];
    struct sockaddr_in tmp;
    socklen_t len = sizeof(tmp); // 入出型参数必须先初始化
    ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                         (struct sockaddr*)&tmp, &len); 
    if (m > 0) { buffer[m] = '\0'; std::cout << "server echo# " << buffer << std::endl; }
}

2.4 客户端为什么不需要显式 bind【必考核心】

✅ 服务端必须显式 bind:服务端端口必须 "众所周知、固定不变",客户端才能找到它;bind 后该端口被当前进程独占。

⚠️ 客户端不需要显式 bind :客户端不是没有端口,只是不显式绑定固定端口。客户端首次调用 sendto 时,内核会自动从临时端口段选一个空闲端口完成隐式绑定,保证端口唯一即可,无需手动指定固定端口。

2.5 云服务器绑定 IP:INADDR_ANY【高频易错】

cpp 复制代码
local.sin_addr.s_addr = INADDR_ANY; // 填充 IP
  • INADDR_ANY 即绑定 0.0.0.0,让 socket 接收本机所有网卡发往该端口的报文,是服务端绑定 IP 的标准写法。
  • ❌ 云服务器直接 bind 绑定公网 IP 必然失败,公网 IP 是 NAT 映射的、不在本机网卡上,bind 会报 EADDRNOTAVAIL。

2.6 实操(CentOS7)

掌握核心流程,先照着跑通,再尝试默写,关键坑点不写错即可。

两端核心流程

步骤 服务端核心流程 客户端核心流程
1 创建套接字:socket (AF_INET, SOCK_DGRAM, 0) 创建套接字:socket (AF_INET, SOCK_DGRAM, 0)(参数与服务端完全一致)
2 绑定地址:填充 sockaddr_in → 调用 bind ()(IP 填 INADDR_ANY,端口用 htons 转网络字节序) 填充目的地址:sockaddr_in 存服务端 IP + 端口(IP 用 inet_addr 转换,端口用 htons 转网络字节序)
3 事件循环:recvfrom 收报文 → 业务处理 → sendto 按原地址回显给客户端 收发循环:sendto 向服务端发数据 → recvfrom 收服务端回显
4 关闭套接字:close (sockfd) 关闭套接字:close (sockfd)

服务端 udp_server.cpp

cpp 复制代码
#include <iostream>
#include <string>       // std::stoi / std::string
#include <cstring>      // strerror / strlen
#include <strings.h>    // bzero
#include <unistd.h>     // close系统调用
#include <sys/socket.h> // 套接字系统调用
#include <netinet/in.h> // 网络地址结构体头文件,sockaddr_in,INADDR_ANY,端口/IP类型
#include <arpa/inet.h>  // 网络字节序转换函数

int main(int argc, char* argv[]) {
    // argc=2代表程序名+1个参数(端口)
    if (argc != 2) { std::cerr << "Usage: " << argv[0] << " port\n"; return 1; }
    uint16_t port = std::stoi(argv[1]); // 字符串转整数,得到主机字节序端口号

    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) { std::cerr << "socket error: " << strerror(errno) << std::endl; return 2; }

    // 2. 绑定地址
    struct sockaddr_in local;
    bzero(&local, sizeof(local));           
    local.sin_family = AF_INET;
    local.sin_port = htons(port);            
    local.sin_addr.s_addr = INADDR_ANY;      
    if (bind(sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
        std::cerr << "bind error: " << strerror(errno) << std::endl; 
        return 3;
    }

    // 3. 事件循环
    char buffer[1024];
    for (;;) { 
        struct sockaddr_in peer;        
        socklen_t len = sizeof(peer);   
        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr*)&peer, &len);
        if (n < 0) { std::cerr << "recvfrom error\n"; continue; }
        buffer[n] = '\0';                    
        std::cout << inet_ntoa(peer.sin_addr) << ":" << ntohs(peer.sin_port)
                  << "# " << buffer << std::endl;
        sendto(sockfd, buffer, strlen(buffer), 0,   
               (struct sockaddr*)&peer, len);
    }
    
    close(sockfd); // 4. 
    return 0;
}

客户端 udp_client.cpp

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main(int argc, char* argv[]) {
    // argc=3代表程序名+2个参数(IP+端口)
    if (argc != 3) { std::cerr << "Usage: " << argv[0] << " server_ip server_port\n"; return 1; }
    uint16_t serverport = std::stoi(argv[2]); // 字符串转整数,得到主机字节序端口号

    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) { std::cerr << "socket error\n"; return 2; }

    // 2. 填充服务端地址
    struct sockaddr_in server;
    bzero(&server, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);                 
    server.sin_addr.s_addr = inet_addr(argv[1]);  // 字符串IP -> 整数网络字节序IP

    // 3. 收发循环
    std::string msg;
    while (std::getline(std::cin, msg)) { // 整行读输入
        // 发服务端:首次sendto时内核自动分配临时端口并隐式bind
        sendto(sockfd, msg.c_str(), msg.size(), 0, 
               (struct sockaddr*)&server, sizeof(server));
        // 收服务端回显
        char buffer[1024];
        struct sockaddr_in tmp;
        socklen_t len = sizeof(tmp); // 入出型参数必须先初始化
        ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr*)&tmp, &len); 
        if (m > 0) { buffer[m] = '\0'; std::cout << "server echo# " << buffer << std::endl; }
    }

    close(sockfd); // 4.
    return 0;
}

编译运行结果

终端1:

终端2:


3. V2 版本:DictServer 字典服务器【业务解耦】

V1 的 Echo 服务器验证了 UDP 收发的基础能力,但服务端"事件循环:recvfrom 收报文 → 业务处理 → sendto 按原地址回显给客户端"将业务逻辑与网络收发耦合,代码结构扩展性差。

cpp 复制代码
char buffer[1024];
for (;;) { 
    struct sockaddr_in peer;        
    socklen_t len = sizeof(peer);   
    // 接收客户端报文
    ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
    if (n < 0) { std::cerr << "recvfrom error\n"; continue; }
    buffer[n] = '\0';      

    // 【业务逻辑 = 打印 + 回显】
    // 转回主机字节序打印对端信息
    std::cout << inet_ntoa(peer.sin_addr) << ":" << ntohs(peer.sin_port) << "# " << buffer << std::endl;
    // 按原地址回显给客户端
    sendto(sockfd, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
}

3.1 网络与业务解耦:回调函数设计【进阶拔高】

考察形式:二面系统设计 / 框架设计追问。

V2 版本我们通过把原本耦合在事件循环里的业务抽离为独立回调函数,UDP 服务器框架只负责网络收发,业务逻辑通过函数注入,实现收发与业务解耦,业务逻辑可灵活替换,便于扩展不同业务。

cpp 复制代码
// 头文件

// 约束回调类型
using handler_t = std::function<void(const std::string&, std::string*)>;

// 【循环外,全局,业务回调函数】
void echo_handler(const std::string& req, std::string* resp) {
    *resp = req;
}

// 声明Handler变量
handler_t Handler; 

int main() {
    ......

    // 进入循环前,回调函数注入
    Handler = echo_handler; 

    // 【循环内只负责网络收发,调用业务回调函数 Handler,不关心具体业务逻辑】
    char buffer[1024];
    for (;;) { 
        struct sockaddr_in peer;        
        socklen_t len = sizeof(peer);  
        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
        if (n < 0) { std::cerr << "recvfrom error\n"; continue; }
        buffer[n] = '\0'; 

        std::string req = buffer;      // 请求
        std::string resp;
        Handler(req, &resp);           // 传入请求、拿到回响

        // 业务处理后的回响发回对应客户端
        sendto(sockfd, resp.c_str(), resp.size(), 0, (struct sockaddr*)&peer, len);
    }
    
    ......
}

比如,替换为字典业务------仅修改循环外业务回调函数,不改动循环代码

cpp 复制代码
void dict_handler(const std::string& req, std::string* resp) {
    *resp = gdict.Translate(req);
}

💡 面试价值:二面常追问 "如何让一个服务器框架支持不同业务" ------ 答 "回调注入Handler" 即可。

3.2 通用封装:UdpSocket + UdpServer【进阶拔高】

基于回调的解耦思路,我们可以进一步把底层套接字操作封装成独立工具类,让上层服务器框架只关注流程调度。

封装版把系统调用包成 bool 方法:UdpSocket::Socket/Bind/RecvFrom/SendTo,再让 UdpServer::Start(ip, port, handler) 走固定三步:bind → 循环 recvfrom → handler 计算 → sendto。

💡笔试 / 面试不要求背封装代码,但要求能说清分层职责:UdpSocket 管收发、UdpServer 管流程、业务回调管语义。


4. V3 版本:简单聊天室【在线用户管理 + 多线程】

V2 实现了一对一的请求 - 响应模式,V3 我们进一步拓展到多客户端广播场景,实现一个简单的 UDP 聊天室,同时引入在线用户管理与多线程全双工设计。

4.1 服务端:首包即登录【进阶拔高】

核心逻辑 :UDP 无连接,没有 "上线 / 下线" 事件。聊天室约定首条消息即登录:收到从未见过的 (IP,端口) 就加入在线列表 vector<InetAddr>,之后每条消息群发给所有在线用户;InetAddr 重载 operator==(比较 IP + 端口)用于查找与删除。

4.2 多线程客户端与 UDP 全双工【进阶拔高】

解决了服务端的在线用户管理与消息路由,我们再来看客户端的优化 ------ 单线程收发会造成界面阻塞,需要引入多线程全双工设计。

  • 利用 UDP 套接字全双工特性(收发各占一个线程,共享同一个 socket 文件描述符可同时读和写):将客户端拆为发送线程(读取键盘输入并发送)和接收线程(阻塞接收服务端消息),解决单线程下 "发完必须等接收" 导致的程序阻塞假死问题。
相关推荐
盛世宏博智慧档案1 小时前
一文读懂 PoE+Modbus TCP 温湿度传感器布线规范与故障排查
网络·网络协议·tcp/ip
wuminyu2 小时前
ForkJoinPool窃取线程在扫描其他WorkQueue时的随机采样算法与Phase线程挂起唤醒机制解析
java·linux·c语言·jvm·c++
liulilittle2 小时前
Linux 下 select 测试函数
linux·服务器·网络·数据库·c++·select·c
夜雪一千2 小时前
Python 生成模拟地址数据:Faker之外的备选库
网络·windows·python
DongQiShanRen2 小时前
玄龙(上):TICK 主循环——意识心跳怎么跳
linux·jvm·数据库·人工智能·数据挖掘·rust
小此方2 小时前
Linux网络(二十一):深入理解 TCP 异常处理:网线断开、Keepalive 保活机制与 Linux 内核传输层协议源码剖析
linux·网络·tcp/ip
pride.li3 小时前
Python 安装
linux·python·ubuntu
笔墨登场说说3 小时前
centos 8 使用 kafka_2.13-4.3.1.tgz 安装kafka
linux·kafka·centos