架构相关 #
内存如何管理的?* #
- 内存监控,阈值警告
- 增加GC
- 使用sync.Pool复用对象
- 使用pprof监控,优化程序,排查内存泄露
主程序对子程序如何监控的? #
一、基本监控流程
- 启动子进程
- 使用
os/exec包的Command创建子进程 - 设置标准输入/输出流
- 调用
Start()方法异步启动子进程
- 使用
- 基础状态监控
- 通过
Wait()方法阻塞等待子进程退出 - 获取子进程的退出状态码
- 区分正常退出与异常退出情况
- 通过
- 进程存活检查
- 定期检查进程PID是否存在
- 验证进程状态是否仍在运行
- 设置超时检测机制
二、高级监控方案
- 资源使用监控
- 定期采集CPU使用率
- 监控内存占用情况
- 跟踪线程/文件描述符数量
- 设置资源使用阈值告警
- 进程组管理
- 创建独立的进程组
- 确保能完整终止整个进程树
- 防止产生孤儿进程
- 健康检查机制
- 实现TCP/HTTP健康检查接口
- 设计业务逻辑就绪检查
- 设置检查超时时间
三、生产级监控流程
- Supervisor模式
- 子进程崩溃后自动重启
- 实现指数退避重试策略
- 设置最大重启次数限制
- 优雅终止流程
- 先发送TERM信号尝试正常退出
- 超时后发送KILL信号强制终止
- 处理残留资源清理
- 集成监控系统
- 暴露Prometheus监控指标
- 对接集中式日志系统
- 实现告警通知机制
通信协议相关 #
WebSocket使用考虑,ws用来做什么?为什么采用这个?有没有调查过还有没有其他更好的方案? #
“gorilla/websocket”
进度更新
通知
其他比如MQ轻量级发布/订阅消息协议等其他消息队列不适合
go里面用什么库实现的ws,通过什么保证它的长连接的? #
第三方库 gorilla/websocket(推荐)
- 特点:
- 功能丰富(支持压缩、自定义协议、并发控制)。
- 高性能,广泛用于生产环境。
长连接保持机制
WebSocket 的长连接依赖以下技术实现稳定性和可靠性:
-
Ping/Pong 帧:通过定时发送 Ping 帧检测连接活性。
-
自定义心跳协议:定期发送业务层心跳消息。
-
读写超时:防止僵死连接占用资源。
-
客户端自动重连:检测到连接关闭后重新建立连接。
高并发优化技巧
-
连接池管理
- 使用
sync.Pool复用 WebSocket 连接对象。 - 限制单个服务的最大连接数(避免资源耗尽)。
- 使用
-
消息广播优化
- 使用select + channel实现非阻塞广播。
-
零拷贝升级
- 使用http.Hijacker直接接管 TCP 连接(减少内存复制)。
WebSocket并发量多少?如何考虑? #
websocket-bench (Go): 一个专门为 WebSocket 设计的 Go 测试工具,可以生成大量并发连接
影响因素:
1. 服务器资源:
- CPU: 处理每个连接都需要一定的 CPU 资源,尤其是在进行消息编解码、业务逻辑处理时。CPU 核心数越多,能处理的并发连接数越多。
- 内存: 每个 WebSocket 连接都会占用一定的内存来存储连接状态、缓冲区等。内存容量越大,能支持的连接数越多。
- 网络带宽: 服务器的网络接口带宽和上行/下行带宽会直接影响数据传输的速度和数量,限制了高吞吐量的并发连接。
- 操作系统限制: 操作系统对打开文件句柄数量(包括网络连接套接字)存在限制(ulimit -n)。需要相应地调整这些限制以支持大量连接。
2. Go 运行时特性:
- Goroutine: Go 的核心并发模型是 Goroutine。每个 WebSocket 连接通常会由一个独立的 Goroutine 来管理。Go 语言的 Goroutine 非常轻量级,调度效率很高,可以轻松地创建成千上万甚至上百万个 Goroutine。
- GMP 模型: Go 的运行时(runtime)通过 Goroutine、M (Machine) 和 P (Processor) 的模型来调度 Goroutine 到操作系统线程上执行。Go 运行时会自动管理这些 M 和 P,以充分利用多核 CPU。
3. WebSocket 连接的特性:
- 连接的活跃度: 是长连接且低活跃度(偶尔发送消息),还是高活跃度(频繁发送消息)?低活跃度的连接占用资源较少。
- 消息的大小和频率: 大而频繁的消息会消耗更多的 CPU 和网络带宽。
- 消息处理逻辑: 服务器端对接收到的消息的处理逻辑(例如,数据库查询、缓存操作、与其他服务通信)的复杂性和耗时性。
- 心跳包: 合理的心跳包机制可以帮助检测和清理不健康的连接,但过于频繁的心跳也会增加服务器负担。
4. 网络基础设施:
- 路由器/负载均衡器: 任何在服务器前的网络设备都可能成为瓶颈。
- 防火墙: 防火墙规则和状态跟踪也会消耗资源。
理论上和实际中的情况:
- 理论上: 由于 Goroutine 的轻量级特性,Go 语言可以轻松地支持 数十万甚至百万级别的并发连接,前提是服务器硬件资源足够,并且连接本身不消耗太多计算资源(例如,只是简单地转发数据)。
- 实际中: 达到数万、数十万并发通常需要 专门的优化和调优,包括:
- 高性能的网络库: 使用像 gorilla/websocket 这样经过优化的库。
- 精简消息处理逻辑: 避免在消息处理中引入过多的 I/O 阻塞操作或 CPU 密集型任务。
- 异步处理: 对于耗时操作,使用 Goroutine 进行异步处理,避免阻塞主连接处理 Goroutine。
- 连接池: 如果需要频繁与其他服务通信,考虑使用连接池。
- 系统参数调优: 调整操作系统如文件句柄限制 (ulimit -n)、TCP/IP 参数等。
- 负载均衡和分布式部署: 将并发压力分散到多台服务器上。
考虑评估过大概能承受多少长连接?如何考虑的? #
测试和评估 WebSocket (WS) 长连接数目是一个关键的步骤,以确保你的服务能够满足预期的并发需求并保持稳定。这通常涉及 压力测试 和 监控。以下是一些详细的方法和步骤:
1. 准备阶段 #
a. 确定测试目标:
- 你希望支持的最大并发连接数是多少?
- 在达到最大并发连接数时,服务的延迟和错误率是什么水平?
- 在什么负载下,服务器资源(CPU、内存、网络)会达到瓶颈?
b. 搭建测试环境:
- 被测服务(Server): 部署你的 Go WebSocket 服务,最好是生产环境的复制品或接近配置。
- 测试客户端(Client): 你需要一个或多个能够模拟大量 WebSocket 连接的客户端。
- 监控工具: 用于实时监控服务器端和客户端的资源使用情况。
c. 选择合适的测试工具:
- 自研测试客户端:
- 优点: 最灵活,可以完全控制连接逻辑、消息发送模式、断线重连策略等,而且可以用 Go 语言实现,与被测服务栈一致,能很好地利用 Go 的并发特性。
- 缺点: 需要投入开发时间。
- 开源测试工具:
- vegeta (Go): 主要用于 HTTP 压力测试,但可以通过自定义攻击来模拟 WS 连接(较复杂)。
- websocket-bench (Go): 一个专门为 WebSocket 设计的 Go 测试工具,可以生成大量并发连接。
d. 准备模拟的客户端行为:
- 连接/断开频率: 连接后立即断开,还是保持长连接?
- 消息发送频率和大小: 连接后是否立即发送消息?发送什么大小的消息?发送频率如何?
- 心跳机制: 如果你的服务有心跳机制,测试客户端需要模拟发送心跳。
- 断线重连: 测试客户端是否支持断线重连?重连的间隔和策略是什么?
- 业务逻辑模拟: 是否需要模拟一些简单的业务交互?
2. 测试执行 #
a. 分批次增加并发连接:
不要一次性将连接数拉满。应以可控的步长逐步增加并发连接数,例如:
- 从 1000 个连接开始。
- 逐步增加到 5000, 10000, 20000, 50000…
- 在每个增量阶段,让连接稳定一段时间,观察服务器状态。
b. 模拟不同的负载模式:
- 纯连接数测试: 只建立连接,不发送大量消息,测试服务器在高连接数下的稳定性。
- 连接 + 心跳测试: 模拟保持连接并发送心跳包,测试心跳对服务器性能的影响。
- 连接 + 数据传输测试: 模拟发送和接收消息,测试在数据传输情况下的性能瓶颈。
c. 监控服务器端资源:
在测试过程中,使用以下工具和指标进行监控:
- top, htop: 查看整体 CPU 和内存使用情况。
- free -m: 查看内存使用情况。
- netstat -an | grep ESTABLISHED | wc -l: 统计当前建立的 TCP 连接数(非常重要)。
- ss -s: 查看 TCP 统计信息,包括连接数、内存使用等。
- Go Runtime 监控:
- 使用 net/http/pprof 包,通过 HTTP 请求获取 Goroutine 数量、CPU 使用率、内存分配等信息。
- 例如:go tool pprof http://localhost:6060/debug/pprof/goroutine
- 应用日志: 检查是否有错误、panic 或连接被异常关闭的记录。
- 网络带宽监控工具: 如 iftop, nload 等。
d. 监控客户端资源:
- 如果客户端也在同一台机器上,也需要监控其资源。
- 如果客户端分布在多台机器上,需要汇总各台客户端的监控数据。
- 关键客户端指标:
- 连接成功率: 尝试建立连接后成功建立的比例。
- 连接错误率: 建立连接时发生的错误(例如,握手失败)。
- 消息发送/接收成功率:
- 消息延迟: 从发送到收到确认(如果需要)的时间。
- 断线率: 连接意外断开的频率。
- 重连成功率和重连延迟。
- 客户端 CPU 和内存使用。
3. 评估和调优 #
a. 分析测试结果:
- 找到瓶颈:
- 是 CPU 耗尽?
- 是内存不足导致 OOM (Out Of Memory) 或频繁 GC?
- 是网络带宽达到上限?
- 是操作系统文件句柄限制 (ulimit -n)?
- 是你的 WebSocket handler 中的某个耗时操作?
- 是 Go runtime 的 Goroutine 调度问题(虽然较少见)?
- 确定最大支持连接数: 找到在可接受的延迟和错误率下,服务器能够稳定支持的最大并发连接数。
b. 进行调优(根据瓶颈):
- 服务器资源:
- 增加 CPU、内存、网络带宽。
- 优化代码:
- 减少不必要的内存分配。
- 使用更高效的数据结构或算法。
- 优化消息处理逻辑,避免阻塞操作。
- 考虑使用连接池(如果需要与其他服务通信)。
- Go Runtime 参数:
- 调整 GOMAXPROCS(通常设置为 CPU 核心数)。
- 调整 GC 相关的参数(谨慎调整)。
- 操作系统参数:
- 提高文件句柄限制 (ulimit -n)。
- 调整 TCP/IP 参数(例如 net.core.somaxconn, net.ipv4.tcp_max_syn_backlog, net.ipv4.tcp_fin_timeout 等)。
- 测试客户端行为:
- 调整连接策略、消息发送频率、心跳间隔等,看是否能改善服务器性能。
- 如果客户端本身成为瓶颈(例如,一台机器模拟不了足够多的连接),需要增加客户端机器数量,并考虑使用分布式压测工具。
c. 迭代测试:
在进行调优后,重复测试执行步骤,直到达到预期的目标或找到最佳的平衡点。
重要的注意事项: #
- 真实场景模拟: 测试越接近真实的业务场景,结果越有参考价值。
- 分布式测试: 当单台机器无法模拟足够多的客户端时,使用多台机器进行分布式压测是必须的。
- 隔离测试环境: 尽量在与生产环境相似但独立的网络和硬件环境中进行测试,避免影响生产服务。
- 监控是关键: 没有完善的监控,你将很难定位瓶颈所在。
有遇到断开的场景吗?非主动断开有了解过吗?为什么会断开? #
- 客户端主动关闭: 客户端调用 Close() 方法。
- 服务器主动关闭: 服务器端代码调用了连接的 Close() 方法。
- 网络异常: 由于网络不稳定、路由器重启、防火墙限制等原因导致连接中断,对方可能没有机会发送关闭帧。
- 超时: 如果没有心跳机制,一段时间没有通信,网络设备或操作系统可能会主动断开连接。
- 资源耗尽: 服务器或客户端资源不足,导致进程崩溃或连接被强制关闭。
如果长连接中间断连怎么办? #
a. 清理资源:
- 释放连接对象: 确保断开的连接对象(例如 websocket.Conn)及其相关资源被正确释放。在 Go 中,通常意味着 Goroutine 退出,其占用的内存和文件句柄会被垃圾回收。
- 从连接池或管理器中移除: 如果你有一个管理所有活跃连接的列表或映射,需要将断开的连接及时移除。这可以防止后续操作尝试向一个已关闭的连接发送消息。
- 取消相关的 Goroutine: 为该连接启动的其他 Goroutine(例如专门用于读取消息的 Goroutine、发送消息的 Goroutine)应该被通知并退出,以避免资源泄露。
- 清理状态: 如果该连接代表一个用户在线状态或某个服务实例,需要更新相应的状态信息。
b. 记录日志:
- 记录连接断开的原因(正常关闭、错误关闭、超时等)。
- 记录断开的连接信息(例如用户 ID、连接 ID)。
- 这对于后续的故障排查和性能分析至关重要。
c. 通知相关方(可选):
- 通知其他连接的用户: 如果用户断开连接会影响到其他用户(例如,某个用户离开聊天室),则需要将此信息广播给其他连接的用户。
- 通知上层业务逻辑: 你的业务逻辑可能需要知道某个用户离线了,以便进行相应的状态更新或通知。
d. 客户端重连机制(非常重要):
这是提升用户体验和服务可用性的核心。当连接断开时,尤其是由于网络问题时,客户端应该尝试重新连接。
- 实现重连逻辑: 在客户端代码中,当 WebSocket 连接发生错误或关闭时,触发重连尝试。
- 指数退避 (Exponential Backoff): 不要立即以相同的间隔不断尝试重连,这会给服务器带来过大压力,也可能因为网络瞬间不稳定而持续失败。使用指数退避策略:
- 第一次重连间隔:例如 1 秒。
- 第二次重连间隔:例如 2 秒。
- 第三次重连间隔:例如 4 秒。
- 可以设置一个最大重连间隔(例如 30 秒 或 1 分钟)。
- 可以引入一些随机抖动 (jitter),以避免大量客户端在同一时间集中重连。
- 最大重试次数: 设置一个最大重连尝试次数,超过后停止重连或进入一个“离线”状态,并提示用户手动重连。
- 状态提示: 在 UI 上清晰地告知用户连接已断开,并显示正在尝试重连或重连失败的信息。
- 携带连接信息: 在重新连接时,客户端可以尝试携带之前连接的身份信息(例如 token),以便服务器能够快速识别用户并恢复其状态。
e. 服务器端如何处理客户端重连:
- 无状态设计: 如果你的服务器是无状态的,并且连接信息(如用户 ID)是直接从客户端传递过来的,那么服务器只需要处理新的连接请求即可,就像新用户连接一样。
- 有状态设计: 如果服务器维护了用户状态(例如,在线状态、聊天室成员列表),那么当客户端重连时,服务器需要识别出是同一个用户重新连接,并更新其状态。这通常通过在连接建立时验证客户端提供的身份凭证(如 JWT token)来实现。
- 幂等性处理: 确保客户端重连后发送的第一个消息(可能是心跳或状态同步请求)是幂等的,这样即使消息被服务器接收多次(例如,在重连过程中),也不会导致错误状态。
ws一次可以发送多大的数据? #
WebSocket(WS)协议本身对单次消息的大小没有硬性限制,但实际传输中受以下因素制约:
1. 理论限制
| 层级 | 限制来源 | 典型值 |
|---|---|---|
| WebSocket 协议 | 协议规范未规定最大消息大小(帧长度字段为 varint,理论支持 2^63-1 字节) |
无协议层限制 |
| TCP/IP 层 | 单次传输的数据包受 MTU(最大传输单元)限制 | 以太网 MTU 通常为 1500 字节(需分片) |
| 实现库限制 | 客户端/服务端库可能设置默认阈值 | 如 gorilla/websocket 默认 512MB |
2. 实际约束因素
(1) 内存限制
- 发送端内存:大消息会占用大量内存(如 1GB 消息需连续内存)。
- 接收端缓冲:未及时处理的消息可能导致缓冲区溢出。
(2) 网络性能
- 分片传输:WS 消息会被拆分为多个 TCP 包,高延迟网络下效率低下。
- 阻塞风险:大消息发送期间可能阻塞其他消息(尤其在单线程环境中)。
(3) 库/框架默认配置
| 库/语言 | 默认限制 | 调整方法 |
|---|---|---|
gorilla/websocket (Go) |
512MB | conn.SetReadLimit(limit) |
websockets (Python) |
默认 1MB,可调 | await websocket.send(data, max_size=None) |
| 浏览器 JavaScript | 无明确限制,但受内存制约 | 需手动分片发送 |
你在后端如何管理长连接?如何识别那个长连接对应那个客户端?两个客户端通过ws连接你,你是如何区分的? #
1. 连接标识符 (Connection Identification) #
每个 WebSocket 连接都需要一个唯一的标识符,以便服务器能够跟踪和区分它们。常见的标识符和方法包括:
- websocket.Conn 对象: Go 语言的 gorilla/websocket 库为每个成功的 WebSocket 握手会话提供一个 *websocket.Conn 对象。这个对象本身在服务器内部是唯一的,但它并不直接告诉你是哪个用户连接。
- 用户ID (UserID): 这是最常见的区分方式。一旦客户端连接上来,服务器需要一种机制来知道这个连接属于哪个用户。这通常通过:
- URL 参数: 在 WebSocket 升级请求的 URL 中携带用户 ID 或认证 token,例如 ws://yourserver.com/ws?user_id=123 或 ws://yourserver.com/ws?token=abcde。
- HTTP Header: 在升级请求的 Header 中携带信息,例如 Sec-WebSocket-Protocol 或自定义 Header。
- 握手后的消息: 在成功建立 WebSocket 连接后,客户端立即发送一条包含用户 ID 或认证信息的消息。服务器解析这条消息后,将其与当前连接关联起来。
- 会话ID (SessionID): 如果你的应用有会话管理机制,可以将一个 session ID 绑定到 WebSocket 连接上。
- 随机生成的连接ID: 即使无法获取用户 ID,也可以为每个连接生成一个唯一的随机 ID,以便在服务器内部进行管理和区分。
- 一个用户ID可能对应多个SessionID(用户多设备登录)
- 一个SessionID只能对应一个用户ID(连接认证后)
如果是群聊,数据怎么发送的?
2. 连接管理器 (Connection Management) #
服务器需要一个结构来存储和管理所有活跃的 WebSocket 连接。常见的实现方式有:
- 全局映射 (Global Map): 使用一个 map[string]*Connection 或 map[string]interface{} 来存储连接,其中 key 是用户的唯一标识符(如 UserID),value 是对连接对象(或包含连接对象的结构体)的引用。
- 例如:var activeConnections = make(map[string]*Connection)
- 并发安全: 由于多个 Goroutine 会并发地读写这个 map(添加新连接、移除断开的连接),必须使用 sync.RWMutex 来保护 map 的访问。
- 广播中心/Hub (Hub and Spoke Model): 这是一个非常流行的模式,尤其在处理大量消息广播时。
- Hub Goroutine: 一个中央的 Goroutine 负责管理所有的连接。
- Channel 通信: 每个客户端连接的 Goroutine 都通过 channel 与 Hub Goroutine 通信。例如:
- register: channel 用于新连接注册。
- unregister: channel 用于断开连接的注销。
- broadcast: channel 用于发送消息给所有连接。
- sendToUser: channel 用于向特定用户发送消息。
- 优点: Hub Goroutine 统一管理连接,避免了直接操作共享 map 的并发问题,逻辑清晰。
3. 将连接与业务实体关联 #
一旦确定了连接的标识符(如 UserID),服务器就可以将这个连接与你的业务实体关联起来。
- 用户在线状态: 当一个用户连接时,更新其在线状态。断开时,更新为离线。
- 用户会话数据: 存储与该用户相关的其他会话数据。
- 消息路由: 根据接收到的消息,决定是将消息广播给所有人,还是发送给特定的用户。
gRPC 底层基于什么协议?为什么使用http/2 #
HTTP/2 提供了 gRPC 所需的高性能、低延迟和多路复用等特性。
HTTP/2 相比 HTTP/1.x 有显著优势,特别适合 gRPC 的 RPC(远程过程调用)场景:
(1) 二进制协议(非文本)
- HTTP/1.1:基于文本(如
GET / HTTP/1.1),解析效率低。 - HTTP/2:二进制帧(Frame)传输,解析更快,更适合机器处理。
(2) 多路复用(Multiplexing)
- HTTP/1.1:每个请求需独立 TCP 连接(或队头阻塞问题)。
- HTTP/2:单个 TCP 连接可并发处理多个请求/响应(通过 Stream ID 区分)。
(3) 头部压缩(HPACK)
- HTTP/1.1:头部重复发送(如
Content-Type: application/json每次都要传)。 - HTTP/2:使用 HPACK压缩头部,减少冗余数据。
(4) 服务器推送(Server Push)
- HTTP/2:服务器可主动推送数据(如预加载资源)。
- 虽然 gRPC 未直接使用此特性,但为未来扩展(如实时通知)提供可能。
(5) 流控制(Flow Control)
- HTTP/2:支持基于流的流量控制(类似 TCP 滑动窗口)。
- gRPC 利用此特性优化大文件传输或流式调用,避免接收方过载。
(6) 更好的 TLS 支持
- HTTP/2 强制推荐 TLS(尽管规范允许明文,但主流实现如浏览器要求 HTTPS)。
- gRPC 默认使用 TLS(如
grpcs://),天然支持加密通信。
- gRPC 默认使用 TLS(如
gRPC 如何利用 HTTP/2? #
(1) 请求-响应映射
| gRPC 元素 | HTTP/2 对应部分 |
|---|---|
| gRPC 请求 | HTTP/2 POST 请求 + application/grpc Content-Type |
| gRPC 方法路径 | HTTP/2 的 :path 头(如 /package.service/method) |
| gRPC 状态码 | HTTP/2 的 grpc-status 尾部头部(Trailer) |
| gRPC 元数据(Metadata) | HTTP/2 的 Headers(如 authorization: Bearer xxx) |
(2) 流式调用实现
- 普通 RPC:单个请求/响应(Unary)。
- 流式 RPC:
- 客户端流:客户端发送多个消息(共用同一个 Stream ID)。
- 服务器流:服务器返回多个消息(通过同一 Stream ID)。
- 双向流:双方独立发送消息(全双工通信)。
http1.0 2.0 3.0之间的区别? #
| 特性 | HTTP/1.0 (1996) | HTTP/1.1 (1997) | HTTP/2 (2015) | HTTP/3 (2022) |
|---|---|---|---|---|
| 传输层协议 | TCP | TCP | TCP | QUIC (基于UDP) |
| 连接复用 | ❌ 不支持 | ✅ 持久连接 | ✅ 多路复用 | ✅ 多路复用 + 改进 |
| 头部格式 | 文本 | 文本 | 二进制帧 | 二进制帧 |
| 头部压缩 | ❌ 无 | ❌ 无 | ✅ HPACK | ✅ QPACK |
| 队头阻塞 (HOL) | ❌ 严重 | ❌ 存在 | ✅ 解决流级阻塞 | ✅ 彻底解决 |
| 服务器推送 | ❌ 无 | ❌ 无 | ✅ 支持 | ✅ 支持 |
| 默认加密 | ❌ 无 | ❌ 无 | ⚠️ 非强制但推荐 | ✅ 强制 TLS 1.3 |
| 典型延迟 | 高 | 中 | 低 | 极低 |
| 握手开销 | 高 (TCP+TLS) | 高 (TCP+TLS) | 高 (TCP+TLS) | ✅ 0-RTT/1-RTT |
客户端如何认证?互联网相关认证方案?两个客户端如何访问区分,鉴权? #
项目认证 #
- 许可文件生成阶段
- 开发商生成包含机器特征码的授权文件
- 使用非对称加密算法签名
- 包含有效期、功能模块等授权信息
- 首次验证流程
- 用户安装软件后首次启动
- 系统采集本机特征信息(CPU序列号/硬盘ID/MAC地址等)
- 提示用户输入许可证文件或激活码
- 验证授权文件的完整性和有效性
- 验证通过后创建本地验证凭证
- 日常验证流程
- 软件启动时检查本地凭证
- 定期(如每周)进行完整验证
- 关键功能调用前进行模块授权验证
其他方案:客户端认证 (Authentication) #
认证的目的是验证客户端是谁,确认它是一个合法的用户。
1. 基于 Token 的认证 (Token-based Authentication) - 最常用 #
这是目前最流行且推荐的方式,因为它 stateless(无状态),易于扩展。
-
流程:
- 用户登录: 用户首先通过传统的 HTTP 请求(例如 POST 到 /login)向服务器提供用户名和密码进行登录。
- 服务器验证: 服务器验证凭证,如果正确,则生成一个 JSON Web Token (JWT) 或一个自定义的会话令牌。这个 token 通常包含用户的 ID、角色、过期时间等信息,并用私钥签名。
- 服务器返回 Token: 服务器将生成的 token 返回给客户端。
- 客户端存储 Token: 客户端将 token 存储在本地(例如 localStorage, sessionStorage, cookie,或内存)。
- WebSocket 连接时的 Token 传递:
- URL 参数: ws://yourserver.com/ws?token=your_generated_jwt_token
- HTTP Header: 在 WebSocket 升级请求的 Sec-WebSocket-Protocol Header 中或自定义 Header 中传递 token。
- 服务器验证 Token: 当 WebSocket 升级请求到达时,服务器会从 URL 参数或 Header 中提取 token,然后使用相同的私钥验证 token 的签名和过期时间。如果 token 有效且未过期,服务器就成功认证了该客户端。服务器会将 token 中的用户信息(如 UserID)与这个 WebSocket 连接关联起来。
-
优点: Stateless,易于横向扩展;可以使用标准的 JWT 库; token 可以包含丰富的信息。
-
缺点: Token 过期后需要重新登录;如果 token 被泄露,可能导致安全问题(可以使用短时效 token + refresh token 机制缓解)。
Refresh Token 是一种长期有效的令牌(Token)
- Access Token 有效期较短(如 1 小时),用于 API 访问。
- Refresh Token 有效期较长(如 7 天),存储在安全的地方(如 HTTP Only Cookie)。
Access Token过期后会,使用Refresh Token自动获取新的Access token
2. 基于 Session/Cookie 的认证 #
这种方式更传统,依赖于服务器端的 Session 状态。
- 流程:
- 用户登录: 用户通过 HTTP 请求登录。
- 服务器创建 Session: 服务器验证凭证,并在服务器端创建一个会话(Session),将其关联到用户数据,并生成一个 Session ID。
- 服务器返回 Session Cookie: 服务器通过 HTTP Set-Cookie Header 将 Session ID 返回给客户端。浏览器会自动在后续的 HTTP 请求中携带这个 Cookie。
- WebSocket 连接时的 Cookie 传递: 当客户端发起 WebSocket 升级请求时,浏览器会自动将之前存储的 Session Cookie 一同发送给服务器。
- 服务器验证 Session Cookie: 服务器从请求中读取 Session Cookie,查找对应的服务器端 Session。如果找到有效的 Session 并且 Session 与一个已登录的用户关联,则认证成功。
- 优点: 与传统的 Web 应用认证流程一致。
- 缺点: Stateful,对服务器的扩展性有一定挑战(需要共享 Session 状态或使用分布式 Session);对于跨域请求,Cookie 的处理可能需要额外配置。
token和session的区别
特性 Session Token(如 JWT) 定义 服务器存储的用户会话状态 自包含的加密字符串,包含用户信息 存储位置 服务器内存/数据库 客户端(LocalStorage/Cookie/内存) 通信方式 依赖 Cookie(SessionID) 通常通过 HTTP Header(如 Authorization: Bearer xxx)状态管理 有状态(服务器维护会话) 无状态(服务器不存储) 扩展性 服务器集群需共享 Session(如 Redis) 天然支持分布式系统 安全性 依赖 HTTPS + Secure Cookie 依赖 HTTPS + 签名验证
3. API Key 认证 #
对于一些不受用户直接操作的后台服务或设备,可以使用 API Key 进行认证。
- 流程:
- 获取 API Key: 服务端预先为客户端(例如一个特定的设备或服务)生成一个 API Key。
- 客户端传递 API Key: 客户端在 WebSocket 连接时,通过 URL 参数或 Header 传递这个 API Key。
- 服务器验证 API Key: 服务器检查 API Key 是否合法,以及是否具有访问权限。
- 优点: 简单直接,适用于机器对机器的通信。
- 缺点: API Key 如果泄露,安全性较低,通常需要定期轮换。
客户端鉴权 (Authorization) #
鉴权是在认证成功之后,服务器决定该客户端 有什么权限 去执行某个操作(例如,是否可以加入某个聊天室,是否可以发送消息给某个用户,是否可以访问特定数据等)。
- 如何实现:
- 角色和权限信息: 在认证成功后,服务器从 token 或 Session 中获取用户的角色(Role)或直接的用户权限列表。
- 基于规则的检查: 在处理 WebSocket 消息或连接事件时,服务器根据客户端的身份(UserID)和其拥有的权限来决定是否允许操作。
- 例如: 如果一个客户端尝试加入一个只有管理员才能加入的“管理频道”,服务器会检查该用户的角色或权限。
- 例如: 如果一个客户端尝试发送消息给另一个用户,服务器会检查该客户端是否被允许向该目标用户发送消息(例如,在私聊场景下,只有双方可以)。
- 将权限与连接关联: 将用户的角色或权限信息与 Client 对象关联起来,方便在 readPump 或 writePump 中进行检查。
token怎么生成,怎么去解析? #
最常见的 Token 格式是 JSON Web Token (JWT),它具有良好的标准化和广泛的库支持。下面将详细介绍 JWT 的生成和解析过程。
JWT 的组成 #
一个 JWT 通常包含三个部分,用点 (.) 分隔:
-
Header (头部): 包含 Token 的类型(typ,通常是 JWT)和所使用的签名算法(alg,例如 HS256 - HMAC using SHA-256,RS256 - RSA using SHA-256)。
- 示例 Header: {“alg”: “HS256”, “typ”: “JWT”}
- Base64 URL 编码后:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
-
Payload (载荷): 包含声明 (claims)。声明是关于实体(通常是用户)和其他数据的陈述。常见的声明类型包括:
- Registered Claims: 标准化的声明,如:
- iss (Issuer): 签发者,Token 的签发者。
- sub (Subject): 签名的 Subject(即用户 ID)。
- aud (Audience): 接收者,Token 的接收者。
- exp (Expiration Time): Token 的过期时间。
- nbf (Not Before): Token 生效的开始时间。
- iat (Issued At): Token 的签发时间。
- jti (JWT ID): Token 的唯一标识符。
- Public Claims: 可以由用户自定义,但应避免包含敏感信息,因为它们是暴露的。
- Private Claims: 由用户自定义,用于在各方之间传递信息。
- 示例 Payload: {“sub”: “1234567890”, “name”: “John Doe”, “iat”: 1516239022}
- Base64 URL 编码后:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
- Registered Claims: 标准化的声明,如:
-
Signature (签名): 用于验证 Token 的完整性和真实性。签名是通过以下步骤生成的:
- 将 Header 和 Payload 的 Base64 URL 编码后的字符串,用点 (.) 连接起来。
- 根据 Header 中指定的算法 (alg),使用一个 密钥 (secret key) 对连接后的字符串进行签名。
- 对签名结果进行 Base64 URL 编码。
- 示例签名(假设使用 HS256 算法和密钥 your-256-bit-secret): SflKxwRJSMeKK924uvpQfHh4aX6c7f8g9h0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z
最终的 JWT 就是这三部分通过点连接起来的字符串:Header.Payload.Signature。
Token 生成过程 (服务器端) #
生成 Token 的过程发生在服务器处理用户成功登录请求之后。
- 准备 Payload:
- 创建一个包含标准声明(iss, sub, exp, iat 等)和自定义声明(如 user_id, role)的 JSON 对象。
- 设置 exp (Expiration Time) 是非常重要的安全措施,它决定了 Token 的有效期。通常使用 time.Now().Add(duration) 来设置。
- 设置 iat (Issued At) 也很常见。
- 创建 Header:
- 创建一个 JSON 对象,指定 alg (签名算法,例如 HS256) 和 typ (JWT)。
- 编码 Header 和 Payload:
- 将 Header 和 Payload 的 JSON 对象分别进行 Base64 URL 编码。
- 注意: Base64 URL 编码与标准的 Base64 编码略有不同,它使用 - 代替 +,使用 _ 代替 /,并且不加尾部的填充符 =。这使得 Token 在 URL 或 HTML 中更安全。
- 生成签名:
- 将编码后的 Header 和 Payload 用点 (.) 连接起来:encodedHeader + “.” + encodedPayload。
- 选择一个安全的 密钥 (secret key)。这个密钥必须保密,并且长度要符合所选算法的要求(例如,HS256 需要一个至少 256 位的密钥)。
- 使用 Header 中指定的算法,配合密钥对连接后的字符串进行签名。
- HMAC 算法 (如 HS256): 使用密钥对数据进行哈希,生成签名。密钥是共享的(服务器内部使用)。
- RSA 算法 (如 RS256): 使用私钥对数据进行签名,公钥用于验证签名。这种方式更安全,因为私钥只有服务器知道,公钥可以分发给其他验证方。
- 将生成的签名再次进行 Base64 URL 编码。
- 组装 Token:
- 将编码后的 Header、编码后的 Payload 和编码后的签名用点 (.) 连接起来,形成最终的 JWT 字符串。
Token 解析过程 (服务器端或客户端) #
解析 Token 的过程是验证其有效性和提取声明。
- 接收 Token: 从客户端的请求(URL 参数、Header 或消息体)中获取 Token 字符串。
- 分割 Token: 将 Token 字符串按点 (.) 分割成三部分:Header、Payload 和 Signature。
- 验证签名:
- 重构签名源: 使用编码后的 Header 和 Payload,用点连接起来:encodedHeader + “.” + encodedPayload。
- 选择算法: 从编码后的 Header 中解析出签名算法 (alg)。
- 使用密钥验证签名:
- HMAC 算法: 使用你在生成时使用的相同密钥,对重构的签名源执行相同的签名操作。然后将生成的签名与 Token 中的 Signature 部分进行 比对。如果两者完全一致,则签名验证通过。
- RSA 算法: 使用你在生成时使用的相同 公钥,对 Token 中的 Signature 部分进行验签。如果验签成功,则签名验证通过。
- 如果签名验证失败,则 Token 无效或已被篡改,应拒绝该请求。
- 解析 Payload:
- 如果签名验证成功,则对编码后的 Payload 进行 Base64 URL 解码。
- 将解码后的字符串解析成一个 JSON 对象。
- 从中提取出声明信息(如 UserID, Role, exp, iat 等)。
- 验证声明 (Claims Validation):
- 过期时间 (exp): 检查 exp 是否小于当前时间。如果小于,则 Token 已过期,无效。
- 生效时间 (nbf): 检查 nbf(如果存在)是否大于等于当前时间。如果大于,则 Token 尚未生效,无效。
- 签发者 (iss) 和接收者 (aud): 检查这些声明是否符合预期。
- 其他自定义声明: 根据业务需求检查其他自定义声明的有效性。
- 使用声明进行授权:
- 如果所有验证都通过,则 Token 是有效的。你可以使用 Payload 中提取的用户信息(如 UserID, Role)来执行后续的业务逻辑,例如加载用户数据,检查用户是否有执行当前操作的权限。
底层基础相关 #
数据库相关 #
有没有处理过高并发和限流,ToC方面的? #
gin框架接口是怎么分层的?项目如何分层的? #
路由分客户端调用的组和插件调用的组,组中安装功能有细分
框架有API层、路由、中间件层、服务层、数据存储层等
数据存储用到了哪些中间件?主要存储哪些数据? #
主要是sqlite,存储结构化与非结构化数据
有导出sqlite报表的情况吗?(报表什么意思) #
有,我们有概览业务,有导出报告、html,pdf报告,以及.cvs相关文件
自己如何做测试的?单元测试?基准测试? #
有,每次做新功能时都会写单元测试,同时也会将单元测试集成到CI里面,每次提交代码都会进行测试。
自己搞一下基准测试
程序如何更新版本的? #
程序启动前会检测是否有最新版,如果有提示用户更新
如果遇到数据库升级如何处理的? #
在数据库中加入版本号,当需要数据库升级时,利用版本号进行区分迁移。
程序异常如何处理的?业务异常等等? #
Panic有日志处理,及recover恢复。并提交记录到服务器后台,同时我们也有接口埋点,任务失败成功我们后台有统计。
有没有安全性的设计?接口有没有可能被其他人调用? #
软件启动会验证许可,验证完成后,后续每次调用接口都会验证许可值(具体研究研究代码)
其他的中间件有了解吗?redis、队列等等 #
补充相关知识
docker怎么使用的? #
补充相关知识
你的程序哪些情况下有用到高并发?不同协程如何通信?有没有遇到并发不完全的情况?比如数据最终不安全? #
协程通信方式:channel、context、sync.Cond
同时操作map,同一个结构体变量、计数器等等
sync.map 加锁、atomic计数等等,减少gc使用sync.pool
有没涉及对象存储?介绍一下 #
无(补充用例)
数据流量比较大的场景有遇到吗?介绍一下,MQ有使用过吗 #
核心特点 #
| 维度 | 说明 |
|---|---|
| 通信模式 | 异步通信(生产者-消费者模型) |
| 协议 | AMQP(RabbitMQ)、MQTT(物联网)、Kafka 自定义协议 |
| 数据格式 | 二进制(如 Protocol Buffers)或文本(JSON/XML) |
| 持久化 | 支持消息持久化(磁盘存储) |
| 拓扑结构 | 发布/订阅(Pub-Sub)、点对点(Queue) |
| 典型产品 | RabbitMQ、Apache Kafka、ActiveMQ、NATS |
优势 #
- 解耦:生产者和消费者无需感知对方存在。
- 削峰填谷:缓冲突发流量,避免系统过载。
- 可靠性:支持消息重试、死信队列、事务消息。
- 扩展性:水平扩展消费者处理能力。
劣势 #
- 延迟较高:通常为毫秒到秒级(取决于配置)。
- 复杂度:需额外维护消息中间件。
典型应用场景 #
- 异步任务处理:订单支付后触发邮件通知。
- 日志收集:Flume + Kafka 实现日志聚合。
- 事件驱动架构:微服务间通过事件同步状态(如库存扣减)。
如果生产者发送消息后,消费者掉线怎么处理? #
消费者消费后会返回ACK响应,若没收到ACK响应,会重回发送队列(需要给予重试限制)
业务深挖 #
项目用到了go的哪些特性? #
高并发、静态编译、错误处理、接口组合等等都有用到
每个服务组件之间如何通信的? #
master与几个单体服务之间使用grpc,插件与master之间使用http和ws
系统难点在什么地方,比较有挑战的事情是哪些?最大的技术难点是什么?用了哪些技术? #
整体业务比较偏底层,每次存在新业务时都具有挑战性。
-
wince数据提取 心理上的挑战
一些老旧的车机系统使用wince系统,里面可能存在一些嫌疑人信息,需要讲wince系统的数据提取成镜像提取出来。需要研究wince系统底层数据读取方法,已经wince软件编程逻辑。编写镜像提取工具安装到wince系统上讲数据读取出来。使用c++编写,整体的挑战在于心理压力这边;因为wince是一个被淘汰的系统,整个工作对我没有任何技术上的提升。后面还是克服了心理上的困难,完成领导交给我的任务。
-
底层数据分析 技术上的挑战
有些底层数据保存在二进制文件中,用E01打开转成16进制的形式;我需要猜测不同字节偏移数据分别代表什么含义。比如每 500字节存储一个视频信息,在这500字节中,前20字节代表视频名称,再6字节代表视频开始时间 再6字节代表结束时间,。。。通道号 时长等等;没有任何的资料需要不断的去猜测。
-
新功能学习新技术 学习上面的挑战
比如上AI 我需要学习python,以及AI的相关知识,在大量开源项目中寻找合适的项目去部署测试效果,以及进行二次封装。
比如刚刚的wince,我需要去学习wince系统编程、c++等
又比如上网络扫描,我需要研究不同监控厂商的监控云台SDK,调用相关的函数实现我们的需求。
比如上导出报告,需要学习vue知识,构建不同数据模版,导出相应的html文件
本地sqlite数据量有多大?有没有遇到性能瓶颈?如何处理的? #
本地sqlite数据量大大小取决于用户取证镜像的大小,以及内容。 最大遇到过一秒需要插入20万条数据
开放性题目 #
行业相关?公司现状?团队规模?其他 #
电商业务订单表,支付环节、支付回调。一天会有几百万单,如何设计订单数据结构?它要支持用户侧的查询,也要支持商家侧的查询,商家会看某类商品的统计,商家可能会退款,用户主动退款等,满足长订单生命周期。日增三百万单,表和表字段、拆分的思路,大概有几个表?商家还需要看这个用户之前有没有购物记录。 #
实现一个停车场的类,满足入场停车、出场收费的需求,车位有三种大中小分别对应不同停车费/小时,不足一小时按一小时算。 #
package main
import (
"fmt"
"time"
)
// 定义常量:车位类型和费率(元/小时)
const (
LargeSpotRate = 10
MediumSpotRate = 7
SmallSpotRate = 5
)
// 车辆信息
type Vehicle struct {
LicensePlate string // 车牌号
VehicleType string // 车辆类型(大、中、小)
EntryTime time.Time // 入场时间
SpotType string // 占用车位类型
}
// 停车位信息
type ParkingSpot struct {
SpotID string // 车位编号
SpotType string // 车位类型(大、中、小)
IsOccupied bool // 是否被占用
Vehicle *Vehicle // 停放的车辆
}
// 停车场
type ParkingLot struct {
Spots map[string]*ParkingSpot // 所有车位(按ID索引)
Vehicles map[string]*Vehicle // 在场车辆(按车牌索引)
}
// 创建新停车场
func NewParkingLot() *ParkingLot {
return &ParkingLot{
Spots: make(map[string]*ParkingSpot),
Vehicles: make(map[string]*Vehicle),
}
}
// 添加车位
func (p *ParkingLot) AddSpot(spotID, spotType string) {
p.Spots[spotID] = &ParkingSpot{
SpotID: spotID,
SpotType: spotType,
IsOccupied: false,
}
}
// 车辆入场
func (p *ParkingLot) Enter(licensePlate, vehicleType string) (string, error) {
// 检查车辆是否已在场
if _, exists := p.Vehicles[licensePlate]; exists {
return "", fmt.Errorf("车辆 %s 已在停车场内", licensePlate)
}
// 查找合适的空车位
var availableSpot *ParkingSpot
for _, spot := range p.Spots {
if !spot.IsOccupied && isVehicleFit(vehicleType, spot.SpotType) {
availableSpot = spot
break
}
}
if availableSpot == nil {
return "", fmt.Errorf("没有合适的车位可供车辆 %s 停放", licensePlate)
}
// 记录车辆信息
vehicle := &Vehicle{
LicensePlate: licensePlate,
VehicleType: vehicleType,
EntryTime: time.Now(),
SpotType: availableSpot.SpotType,
}
// 更新车位状态
availableSpot.IsOccupied = true
availableSpot.Vehicle = vehicle
p.Vehicles[licensePlate] = vehicle
return availableSpot.SpotID, nil
}
// 车辆出场并计算费用
func (p *ParkingLot) Exit(licensePlate string) (float64, error) {
vehicle, exists := p.Vehicles[licensePlate]
if !exists {
return 0, fmt.Errorf("车辆 %s 未在停车场内", licensePlate)
}
// 找到占用的车位
var occupiedSpot *ParkingSpot
for _, spot := range p.Spots {
if spot.Vehicle != nil && spot.Vehicle.LicensePlate == licensePlate {
occupiedSpot = spot
break
}
}
if occupiedSpot == nil {
return 0, fmt.Errorf("无法找到车辆 %s 占用的车位", licensePlate)
}
// 计算停车时长(小时)
duration := time.Since(vehicle.EntryTime)
hours := duration.Hours()
if duration.Minutes() > 0 && hours < 1 {
hours = 1 // 不足1小时按1小时计算
} else {
hours = float64(int(hours)) // 超过1小时取整
if duration.Minutes() > 0 {
hours += 1
}
}
// 计算费用
var rate int
switch occupiedSpot.SpotType {
case "large":
rate = LargeSpotRate
case "medium":
rate = MediumSpotRate
case "small":
rate = SmallSpotRate
default:
return 0, fmt.Errorf("未知的车位类型: %s", occupiedSpot.SpotType)
}
fee := float64(rate) * hours
// 释放车位
occupiedSpot.IsOccupied = false
occupiedSpot.Vehicle = nil
delete(p.Vehicles, licensePlate)
return fee, nil
}
// 检查车辆是否适合车位类型
func isVehicleFit(vehicleType, spotType string) bool {
// 大型车只能停大车位
if vehicleType == "large" {
return spotType == "large"
}
// 中型车可停大、中车位
if vehicleType == "medium" {
return spotType == "large" || spotType == "medium"
}
// 小型车可停所有车位
return true
}
func main() {
// 创建停车场
parkingLot := NewParkingLot()
// 添加车位
parkingLot.AddSpot("L1", "large")
parkingLot.AddSpot("L2", "large")
parkingLot.AddSpot("M1", "medium")
parkingLot.AddSpot("M2", "medium")
parkingLot.AddSpot("S1", "small")
parkingLot.AddSpot("S2", "small")
// 模拟车辆入场
spotID, err := parkingLot.Enter("京A12345", "medium")
if err != nil {
fmt.Println("入场失败:", err)
} else {
fmt.Printf("车辆 京A12345 已停放在车位 %s\n", spotID)
}
// 模拟停车2小时15分钟
time.Sleep(2*time.Hour + 15*time.Minute) // 实际使用时不需要,这里模拟时间流逝
// 车辆出场
fee, err := parkingLot.Exit("京A12345")
if err != nil {
fmt.Println("出场失败:", err)
} else {
fmt.Printf("车辆 京A12345 停车费: %.2f 元\n", fee)
}
}
ABC 三个协程,A输出a,B输出b,C输出c,怎么让他们按abc这样循环往复按顺序出现? #
一个通道
三个通道
func main() {
var (
cond = sync.NewCond(&sync.Mutex{})
state = "a" // 初始状态
count = 0
)
var wg sync.WaitGroup
printLetter := func(letter, nextState string) {
defer wg.Done()
for {
cond.L.Lock()
for state != letter && count < 15 { // 总次数限制
cond.Wait()
}
if count >= 15 {
cond.L.Unlock()
return
}
fmt.Print(letter)
state = nextState
count++
cond.Broadcast() // 唤醒所有协程
cond.L.Unlock()
}
}
wg.Add(3)
go printLetter("a", "b") // A → B
go printLetter("b", "c") // B → C
go printLetter("c", "a") // C → A
wg.Wait()
fmt.Println()
}
你做的性能优化的工作有哪些? #
sqlite数据库插入、ai分析提速、(补充)
为什么使用SQLite数据库? #
SQLite 是一个轻量级的嵌入式关系型数据库,
- 无需独立服务:SQLite 以库的形式嵌入应用,无需安装、配置或管理数据库服务器。
- 单文件存储:所有数据(表、索引、视图)存储在单个文件中(如
.db或.sqlite),便于备份和迁移。 - 低延迟读写:直接访问本地文件,避免网络开销,适合单机高频读写(如 CLI 工具、移动应用)。
- 事务支持:支持 ACID 事务,确保数据一致性。
- 内存消耗小:运行时仅需几百 KB 内存,适合嵌入式设备(如 IoT 设备、路由器)。
- 无依赖:编译后无外部依赖,适合静态链接。
有没有做过部署云端上的项目吗? #
整个项目可以部署在云端,基本上是一致的
项目里的AI是做什么的? #
channl的作用,带缓冲和不带缓冲的什么区别,分别针对什么场景? #
| 特性 | 不带缓冲 Channel | 带缓冲 Channel |
|---|---|---|
| 阻塞条件 | 发送/接收必须配对 | 缓冲区满/空时阻塞 |
| 同步性 | 强同步(Goroutine直接交互) | 弱同步(通过缓冲区解耦) |
| 性能 | 高延迟(等待对方) | 低延迟(缓冲区内快速存取) |
| 典型用途 | 事件通知、严格顺序控制 | 任务队列、流量控制、异步处理 |
协程是不是越多越好? #
不是,p队列的大小默认等于核心数,M最初也默认等于核心数
| 因素 | 说明 | 示例场景 |
|---|---|---|
| CPU 密集型 | 协程数 ≈ CPU 核心数(避免过多上下文切换) | 图像处理、加密解密 |
| I/O 密集型 | 协程数可远高于核心数(等待 I/O 时不占用 CPU) | HTTP API 服务、数据库查询 |
| 任务粒度 | 细粒度任务适合更多协程(如微批处理) | 日志分析、事件流处理 |
| 外部系统限制 | 受限于数据库连接池、第三方 API 并发配额等 | MySQL 最大连接数、Rate Limiting |
如果遇到性能问题如何分析?具体怎么使用的?怎么定位的? #
协程和线程有什么区别? #
数组和切片有什么区别? #
| 特性 | 数组(Array) | 切片(Slice) |
|---|---|---|
| 长度固定性 | 长度固定(声明时确定,不可变)[3]int 和 [5]int 是不同的类型 |
长度动态(可动态扩展或缩减) |
| 底层存储 | 直接存储数据(值类型)连续内存块,直接存储数据 | 引用类型(底层依赖数组,存储指针、长度、容量) |
| 传递方式 | 值传递(赋值/传参时复制整个数组) | 引用传递(共享底层数据) |
| 声明方式 | var arr [3]int |
var s []int 或 make([]int, 3) |
| 内存分配 | 编译时静态分配 | 运行时动态分配 |
go推荐使用切片
map是线程安全的吗?底层数据结构是什么? #
mysql的索引优化、慢查询如何处理? #
学习补充
如何学习一些新技术? #
āi、谷歌、GitHub、各大论坛
插件有考虑过其他方式吗? #
微服务、三层架构,不适合
python和go的优势和缺点? #
| 维度 | Python | Go |
|---|---|---|
| 执行方式 | 解释执行(慢) | 编译执行(快) |
| 类型系统 | 动态类型(灵活但易错) | 静态类型(安全但繁琐) |
| 并发模型 | 多线程(GIL限制)/协程(显式异步) | Goroutine(轻量级,原生支持) |
| 学习曲线 | 平缓(适合初学者) | 中等(需理解并发和指针) |
| 典型应用 | AI、数据分析、脚本 | 云计算、网络服务、工具开发 |
面向对象好,还是不面像对象好? #
| 维度 | 面向对象(OOP) | 面向过程/函数式 |
|---|---|---|
| 核心思想 | 通过对象(数据+方法)建模现实世界 | 用函数/过程描述逻辑流 |
| 代码组织 | 高内聚、低耦合(类/接口/继承) | 线性流程(函数调用链) |
| 典型语言 | Java/C#/Python | C/Go/Rust/Haskell |
| 适用场景 | 复杂业务系统、GUI框架、游戏开发 | 系统编程、算法密集型任务、并发处理 |
slice调用append添加数据发生了什么? #
你在项目中用到了那些设计模式? #
视频分析插件用到了策略模式与模版模式
网络扫描用到了工厂模式
什么是单例模式? #
就是一个结构体,一个方法,结构体声明一个变量,调用这个方法。
对于内存中只存在一个对象,且需要频繁创建和销毁对象的系统,使用单例模式可以提升系统稳定性。
比如:数据库连接池、线程池、日志管理器等
面向对象的特征有哪些? #
go不像java或c++那样完全面向对象,通过独特的语言设计实现了面向对象的核心特征。
- 类型与方法的组合(代替类)
- 接口(interface)实现多态
- 嵌入与组合(替代继承)
- 指针接收者和值接收者
- 空接口和断言
Python 是一门完全面向对象的编程语言,其面向对象编程(OOP)特性非常丰富,支持类、继承、多态、封装等核心概念。以下是 Python 面向对象的主要特征:
1. 类(Class)与对象(Object) #
- 类(Class) 是对象的模板,用于定义对象的属性和方法。
- 对象(Object) 是类的实例,具有类定义的属性和行为。
class Person:
def __init__(self, name, age): # 构造函数
self.name = name # 实例属性
self.age = age
def greet(self): # 实例方法
print(f"Hello, I'm {self.name}!")
# 创建对象
p = Person("Alice", 25)
p.greet() # 输出: Hello, I'm Alice!
2. 封装(Encapsulation) #
- 私有变量:使用
_或__前缀表示私有(约定俗成,Python 没有真正的私有变量)。 - Getter/Setter:通过
@property和@<attr>.setter控制访问。
class BankAccount:
def __init__(self, balance):
self.__balance = balance # 私有变量(名称修饰:_BankAccount__balance)
@property
def balance(self): # Getter
return self.__balance
@balance.setter
def balance(self, value): # Setter
if value >= 0:
self.__balance = value
else:
raise ValueError("Balance cannot be negative!")
account = BankAccount(1000)
account.balance = 2000 # 通过 setter 修改
print(account.balance) # 2000
3. 继承(Inheritance) #
- 单继承:子类继承父类的属性和方法。
- 多继承:Python 支持多继承(多个父类)。
- 方法重写(Override):子类可以覆盖父类的方法。
class Animal:
def speak(self):
print("Animal sound")
class Dog(Animal): # 单继承
def speak(self): # 方法重写
print("Woof!")
class Cat(Animal):
def speak(self):
print("Meow!")
class RobotDog(Dog, Animal): # 多继承
def speak(self):
print("Beep!") # 方法重写
dog = Dog()
dog.speak() # 输出: Woof!
robot_dog = RobotDog()
robot_dog.speak() # 输出: Beep!
4. 多态(Polymorphism) #
- 动态绑定:Python 是动态类型语言,方法调用取决于对象的实际类型。
- 鸭子类型(Duck Typing):只要对象有相同的方法,就可以互换使用。
def make_sound(animal):
animal.speak() # 只要 animal 有 speak() 方法即可
make_sound(Dog()) # 输出: Woof!
make_sound(Cat()) # 输出: Meow!
5. 魔术方法(Magic Methods / Dunder Methods) #
- Python 提供特殊方法(如
__init__,__str__,__add__),用于运算符重载和对象行为定制。
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other): # 重载 + 运算符
return Vector(self.x + other.x, self.y + other.y)
def __str__(self): # 定义 print() 时的输出
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # 输出: Vector(4, 6)
6. 类方法与静态方法 #
- 类方法(@classmethod):操作类本身(
cls参数)。 - 静态方法(@staticmethod):不依赖类或实例,类似普通函数。
class MathUtils:
@staticmethod
def add(a, b):
return a + b
@classmethod
def from_string(cls, s): # 工厂方法
x, y = map(int, s.split(","))
return cls(x, y)
print(MathUtils.add(1, 2)) # 3
point = MathUtils.from_string("3,4")
7. 抽象基类(ABC) #
- 使用
abc模块定义抽象类,强制子类实现特定方法。
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self): # 必须实现抽象方法
return 3.14 * self.radius ** 2
# Shape() # 报错:不能实例化抽象类
c = Circle(5)
print(c.area()) # 78.5
8. 动态属性与方法 #
- Python 允许运行时动态添加属性和方法。
class DynamicClass:
pass
obj = DynamicClass()
obj.new_attr = "Hello" # 动态添加属性
print(obj.new_attr) # Hello
def new_method(self):
print("Dynamic method!")
DynamicClass.dynamic_method = new_method # 动态添加方法
obj.dynamic_method() # 输出: Dynamic method!
总结 #
| 特性 | Python 实现方式 |
|---|---|
| 类与对象 | class 关键字,__init__ 构造函数 |
| 封装 | _ / __ 私有变量,@property |
| 继承 | 单继承、多继承,super() 调用父类 |
| 多态 | 动态绑定,鸭子类型 |
| 魔术方法 | __str__, __add__ 等 |
| 类方法 & 静态方法 | @classmethod, @staticmethod |
| 抽象类 | abc.ABC, @abstractmethod |
| 动态特性 | 运行时修改类/对象 |
Python 的 OOP 设计灵活且强大,适合快速开发和动态扩展,同时支持多种编程范式(如函数式编程)。
Redis缓存更新策略 #
对于读数据,我会选择旁路缓存策略,如果 cache 不命中,会从 db 加载数据到 cache。
对于写数据,我会选择更新 db 后,再删除缓存。

缓存是通过牺牲强一致性来提高性能的,这是由CAP理论决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP。所以,如果需要数据库和缓存数据保持强一致,就不适合使用缓存。
所以使用缓存提升性能,就是会有数据更新的延迟。这需要我们在设计时结合业务仔细思考是否适合用缓存。然后缓存一定要设置过期时间,这个时间太短、或者太长都不好:
- 太短的话请求可能会比较多的落到数据库上,这也意味着失去了缓存的优势。
- 太长的话缓存中的脏数据会使系统长时间处于一个延迟的状态,而且系统中长时间没有人访问的数据一直存在内存中不过期,浪费内存。
但是,通过一些方案优化处理,是可以最终一致性的。
针对删除缓存异常的情况,可以使用 2 个方案避免:
- 删除缓存重试策略(消息队列)
- 订阅 binlog,再删除缓存(Canal+消息队列)
消息队列方案
我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
- 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
- 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
举个例子,来说明重试机制的过程。

重试删除缓存机制还可以,就是会造成好多业务代码入侵。
订阅 MySQL binlog,再操作缓存
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

将binlog日志采集发送到MQ队列里面,然后编写一个简单的缓存删除消息者订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性
run关键字和byte的区别? #
| 特性 | rune |
byte |
|---|---|---|
| 底层类型 | int32(4字节) |
uint8(1字节) |
| 表示范围 | Unicode 码点(支持所有字符,如中文、emoji) | 原始字节(0~255,ASCII 或二进制数据) |
| 用途 | 处理文本(尤其是多字节字符) | 处理二进制数据(如文件、网络流) |
2. 关键区别详解 #
(1) 字符 vs 字节 #
-
rune:表示一个 Unicode 字符(可能是单字节或多字节)。
s := "你好" fmt.Println(len([]rune(s))) // 输出 2(2个字符) fmt.Println(len([]byte(s))) // 输出 6(UTF-8编码下占6字节) -
byte:表示一个 字节,可能是字符的一部分(如 UTF-8 编码的中文字符占 3 字节)。
b := byte('A') // ASCII 字符 'A' 的字节值(65)
字符串遍历
-
range遍历字符串时返回rune:s := "Hello, 世界" for i, r := range s { // r 是 rune 类型 fmt.Printf("%d: %c\n", i, r) }输出:
0: H 1: e 2: l 3: l 4: o 5: , 6: 7: 世 10: 界注意:中文字符的索引跳跃(每个占 3 字节)。
-
按
byte遍历:可能拆分多字节字符。for i := 0; i < len(s); i++ { fmt.Printf("%d: %c\n", i, s[i]) // s[i] 是 byte 类型 }输出:
0: H 1: e 2: l 3: l 4: o 5: , 6: 7: ä 8: ¸ 9: \u0080 10: ç 11: \u0095 12: \u008c乱码原因:UTF-8 的中文字符被拆分为多个字节。
-
rune和byte的转换:r := '世' // rune 类型 b := byte(r) // 错误:可能丢失数据(rune 超出 byte 范围) b := []byte(string(r)) // 正确:通过字符串转换 -
string的底层: Go 的字符串本质是[]byte,但通过rune视角处理 Unicode。
适用场景
| 场景 | 推荐类型 | 原因 |
|---|---|---|
| 处理多语言文本(如中文、emoji) | rune |
按字符操作,避免拆分多字节字符 |
| 文件读写或网络传输原始数据 | byte |
直接操作二进制流,无需字符编码转换 |
| 字符串长度计算(按字符数) | rune |
len([]rune(s)) 返回字符数,而非字节数 |
| 加密/压缩算法实现 | byte |
算法通常基于字节处理 |
性能与内存
- 内存占用:
rune固定 4 字节,byte固定 1 字节。- 使用
rune切片([]rune)比[]byte占用更多内存。
- 转换开销:
string↔[]rune或[]byte需内存分配和数据复制。
常见误区
-
误用
len(string):s := "世界" fmt.Println(len(s)) // 输出 6(字节数),而非 2(字符数)正确方式:
len([]rune(s))。 -
byte截断 Unicode:s := "世界" fmt.Println(s[0]) // 输出 228(首字节),非完整字符
go语言的栈空间管理 #
Go 语言的栈空间管理是其运行时系统的核心设计之一,以高并发、低开销、自适应扩容为特点。以下是 Go 栈管理的深度解析:
栈的底层实现机制
(1) 分段栈(Segmented Stack,Go 1.2 前)
- 原理: 每个 Goroutine 初始分配小块栈(如 2KB),栈不足时插入“栈分裂”(stack split)链接新栈段。
- 问题:
- 热分裂问题(Hot Split):频繁扩缩容导致性能抖动(如递归函数反复跨越栈边界)。
- 指针跨段问题:栈段间指针处理复杂,影响 GC 效率。
(2) 连续栈(Contiguous Stack,Go 1.3 起)
- 原理:
- 初始分配较小栈(通常 2KB),栈不足时动态扩容(通常 2倍)。
- 使用栈拷贝(Copy Stack):将旧栈数据复制到新分配的更大连续内存。
- 优势:
- 消除热分裂问题,指针处理简单化。
- 扩容策略平滑,减少内存碎片。
关键设计参数
| 参数 | 默认值 | 说明 |
|---|---|---|
| 初始栈大小 | 2KB | 每个 Goroutine 启动时的栈空间 |
| 最大栈大小 | 1GB | 防止单个 Goroutine 耗尽内存 |
| 扩容阈值 | 栈使用率>80% | 触发扩容的阈值 |
| 缩容阈值 | 栈使用率<25% | 缩容时机(Go 1.4+ 已禁用自动缩容) |
3. 栈扩容/缩容流程
(1) 扩容触发条件
- 触发场景:
- 函数调用层级过深(如深度递归)。
- 局部变量占用过大(如大数组
var buf [1024]int)。
(2) 缩容策略(历史变迁)
- Go 1.0-1.3:自动缩容(易引发抖动)。
- Go 1.4+:禁用自动缩容,仅在高水位线(high watermark)后复用空闲栈。
与系统线程栈的对比
| 特性 | Go Goroutine 栈 | 系统线程栈 |
|---|---|---|
| 初始大小 | 2KB | 通常 2MB(Linux默认) |
| 扩容方式 | 运行时自动管理(连续栈) | 固定大小或手动调整 |
| 内存分配位置 | 堆内存(GC管理) | 虚拟内存(内核管理) |
| 创建开销 | 微秒级 | 毫秒级 |
go服务端怎么调用python插件 #
sqlite技术选型,为什么用这个数据库,和mysql有什么区别? #
Gorm gen知道吗 #
怎么定义gorm的结构体,怎么把结构体和具体的字段如何映射? #
gorm标签是什么机制去操作的?怎么绑定的? #
反射知道吗,做什么的?怎么通过反射修改变量的值? #
mysql简历索引有什么要注意点? #
联合索引?建立 索引ABC ,第一种情况查a=1,第二种情况查:b=1 第三种情况查:a=1and c=1,这三种情况有什么区别吗,从索引的命中来看? #
查询a=1 and b=1和c=1 and a=1,这两种情况对命中索引的情况来看有什么区别?是否命中索引 #
mysql索引优化有做过吗? #
数据库的死锁有遇到吗,怎么查询? #
数据库底层数据结构是什么?红黑树?B+树? #
B+树和mysql的关系? #
什么情况下,索引会失效? #
redis有过吗?简单介绍一下?redis的几种日志类型? #
redis为什么这么快?架构上面?zset有没有用过?它能做什么功能应用? #
分布式锁有用过吗?介绍一下 #
分布式锁是控制分布式系统中多个进程或服务器对共享资源进行互斥访问的一种机制。
一个健壮的分布式锁,通常需要满足以下几个特性:
- 互斥性
- 无死锁:即使持有锁的客户端因为奔溃活网络问题没有主动释放,其他客户端也必须最终能获取到锁。超时机制
- 容错性:提供锁服务的节点应该是高可用的,一部分节点的故障不应该导致整个锁服务不可用。
- 可重入性:同一个客户端或线程在已持有锁的情况下,可以再次成功获取该锁,而不会造成死锁。
目前最流行,性能最高的是使用redis来实现
-
正确的实现方式 (原子操作): 使用Redis的SET命令,并带上NX和EX选项。 SET lock_key random_value NX EX 30
- lock_key: 锁的名称,例如 lock:product_123。
- random_value: 一个随机的、唯一的字符串(如UUID)。用来标识锁的持有者,防止误删。
- NX (if Not eXists): 表示只有当key不存在时,SET操作才会成功。这保证了互斥性。
- EX 30: 设置键的过期时间为30秒。这解决了死锁问题,即使客户端崩溃,锁也会在30秒后自动释放。
-
解锁的正确方式 (原子操作): 不能直接用DEL命令,因为可能误删别人的锁(比如自己的锁已超时,别人获取了新锁)。需要使用Lua脚本来保证“判断”和“删除”的原子性。
-- Lua脚本 if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end这个脚本的逻辑是:只有当key的值等于客户端加锁时设置的那个random_value时,才执行删除操作。
-
优点:
- 性能极高,Redis是内存数据库,读写速度快。
- 命令本身支持超时,天然解决了死锁问题。
- 通过Lua脚本可以实现安全的原子解锁。
-
缺点:
- 在Redis Sentinel或Cluster模式下,主从切换的瞬间可能导致锁的安全性问题(例如,Master节点加锁成功但数据未同步到Slave时宕机,Slave提升为新Master后,另一个客户端可以再次加锁成功)。RedLock算法尝试解决这个问题,但它也存在争议。
如何解决?
-
不依赖单个Reids实例,而是向多个Redis实例申请加锁
首先这个节点不进行主从复制
当从超过半数的节点获取锁成功,则获取锁成功
- 缺点:太复杂、性能下降、不能解决所有问题、时钟漂移问题:Redlock严重依赖各个节点的时钟是基本同步的。如果某个节点的时钟发生跳变,可能导致锁的有效时间计算出错,从而破坏互斥性。
-
使用WAIT命令实现同步复制
-
客户端在执行SET … NX EX …加锁命令之后,立刻执行WAIT num_replicas timeout命令。 #
- num_replicas: 指定需要等待多少个从节点确认收到了写命令。
- timeout: 等待的超时时间(毫秒)。
- WAIT命令会阻塞,直到指定数量的从节点确认了刚刚的SET操作,或者直到超时。
- 只有当SET命令成功 并且 WAIT命令返回的已同步从库数量>=1(或你期望的数字)时,客户端才认为锁获取成功。否则,就认为失败并释放锁。
流程示例:
- 客户端A向Master发送 SET my_lock random_value NX EX 30。
- Master执行成功,返回OK。
- 客户端A接着发送 WAIT 1 500 (等待至少1个从库在500ms内确认)。
- Master将SET命令复制给Slave,并等待Slave的确认。
- Slave确认后,Master向客户端A返回1。客户端A认为加锁彻底成功。
- 如果此时Master崩溃,由于数据已经确保在Slave上,故障转移后新的Master节点上依然有这个锁,客户端B就无法再次获取。
优点:
- 逻辑清晰,简单有效:直接解决了数据未复制的问题。
- 可靠性高:只要WAIT成功,就能确保锁数据至少在一个从库中存在。
缺点:
- 牺牲性能 (增加延迟):加锁操作从一次RTT(Round-Trip Time)变成了至少两次,并且需要等待主从复制的延迟,整个加锁耗时会明显增加。
-
-
引入Fencing Token
- 当客户端成功获取锁时,锁服务(如Redis)不仅返回成功,还会返回一个单调递增的数字,称为fencing token(或叫epoch、generation)。
- 客户端去访问共享资源(如数据库、存储服务)时,必须带上这个token。
- 共享资源的服务端会维护一个token,只有当收到的请求token大于当前存储的token时,才接受操作,并更新自己的token。
示例:
- 客户端A获取锁,得到token=3。
- 客户端A因为GC暂停了很长时间,锁超时了。
- 客户端B获取了同一个锁,得到token=4。
- 客户端B访问资源,带着token=4,资源端接受了操作,并将内部token更新为4。
- 客户端A从GC中恢复,它依然认为自己持有锁,带着旧的token=3去访问资源。
- 资源端发现请求的token=3小于当前token=4,拒绝该操作。
-
使用zookeeper/etcd
- 缺点:性能低,运维复杂
使用ZooKeeper实现
使用etcd实现
| 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库 | 实现简单,无需额外组件 | 性能差,有死锁风险,功能弱 | 对性能要求不高的简单场景,或作为备用方案 |
| Redis | 性能极高,实现相对简单,功能强大 | 集群模式下有极端情况的可靠性问题 | 绝大多数高并发场景,如秒杀、抢红包等 |
| ZooKeeper/etcd | 可靠性极高,一致性强,无死锁 | 性能不如Redis,运维成本高 | 对可靠性和一致性要求极高的金融级业务场景 |
你对服务端分布式系统架构的理解,如何做一个高可用的分布式系统?计算节点、网关、数据存储、代理、计算服务器、读写分离等?这种架构如何完成高可用分布式系统? #
golang context包? #
- 取消协程
- 超时时间
- 截止时间
- k-v,设置键值对,在上下文传递,
context.WithValue()+ 自定义 Key 类型
一个电脑上如何查看已经建立多少个链接,怎么查主机等性能命令 ,怎么查trace? #
netstat -anv
netstat -anv tcp //仅显示tcp链接
如果 netstat -v 没有显示进程名,可以使用 lsof 命令:
lsof -i -P
-i:显示网络连接。-P:禁用端口号转服务名(直接显示数字端口)。
Linux 系统 #
(1) 实时监控工具 #
-
top(动态查看进程资源占用):
top -
htop(增强版,需安装):
htop -
内存使用:
free -h -
磁盘空间:
df -h -
磁盘I/O:
iostat
(2) 系统信息 #
-
CPU信息:
lscpu -
内存详情:
cat /proc/meminfo -
内核版本:
uname -a
路由追踪 #
默认路由追踪 #
traceroute www.example.com
使用ICMP协议(避免防火墙拦截)
traceroute -I www.example.com
实时路由监控 #
mtr www.example.com
一个主机可以建立多少个连接,由什么决定的,http连接。对端只有一个ip,可以建立多少个连接? #
从理论上讲,一台服务器可以支持的TCP连接数是一个天文数字。因为服务器通常监听在一个固定的IP地址和端口上,而客户端的IP地址和端口号是可变的。对于IPv4,客户端IP地址有大约2^32个可能性,客户端端口号有2^16(65536)个可能性。因此,一台服务器理论上可以与全球的客户端建立2^32 * 2^16个连接
端口号的数量
理论值:2^32=65536个
TCP 端口号的分类
类型 范围 说明 知名端口 0~1023 预留给系统服务(如 22=SSH,80=HTTP,443=HTTPS)。需 root 权限绑定。注册端口 1024~49151 分配给用户级应用(如 3306=MySQL,5432=PostgreSQL)。动态/私有端口 49152~65535 客户端临时使用(如浏览器访问网站时会随机分配一个端口)。
实际限制 #
- 操作系统
- 文件描述符:在Linux系统中,每个网络连接都被视为一个文件,需要占用一个文件描述符。操作系统对于单个进程以及整个系统可以打开的文件描述符都有数量限制。
- 端口范围配置:作为客户端发起连接时,主机会从一个临时的端口号范围中选取一个员端口。这个范围的大小会限制从本机到同一个目标地址和端口的连接数。
- 硬件资源
- 内存:每条TCP连接都会在操作系统内核中占用一定的内存资源,用户维持连接状态、收发缓冲区等。
- CPU:处理网络数据包、维护大量连接状态都需要消耗CPU资源。
对端只有一个ip,可以建立多少个连接? #
从客户端角度 #
当你的主机作为客户端,去连接一个只有一个IP地址的服务器的特定服务端口(例如,Web服务器的80或443端口)时,此时四元组中的目的IP、目的端口和源IP都是固定的。唯一可变的就是**源端口号。
由于TCP端口号是16位的,总共有65536个(0-65535)。除去一些保留端口,客户端可用的临时端口号数量大约在6万个左右。因此,一个客户端IP最多只能与一个服务器IP的特定端口建立大约65535个连接。这是客户端侧最直接的限制。
从服务器端角度 #
服务器端只监听在一个端口上(例如80端口),但它可以同时接收来自许多不同客户端的连接。只要客户端的IP地址或客户端的源端口号不同,四元组就不同,服务器就能区分并建立新的连接。
因此,对于服务器来说,它能与一个拥有单一IP的客户端建立的连接数如上所述,受限于该客户端的可用端口号(约6.5万个)。但如果考虑所有客户端,服务器能建立的总连接数则主要受限于自身的硬件资源和操作系统配置,远超6.5万个,可以达到数十万甚至上百万。
go里面进程性能问题如何排查?pprof还能用来做什么? #
pprof 的核心功能
| 功能 | 作用 |
|---|---|
| CPU Profiling | 分析程序各函数的 CPU 占用时间,找出计算密集型瓶颈。 |
| Memory Profiling | 跟踪内存分配和泄漏(堆/栈内存)。 |
| Block Profiling | 检测协程阻塞(如锁竞争、I/O 等待)。 |
| Goroutine Profiling | 统计当前所有协程的执行状态(运行中、阻塞中等)。 |
| Mutex Profiling | 分析互斥锁的竞争情况,定位锁等待问题。 |
| Thread Creation | 跟踪线程创建情况(较少使用)。 |
AB两个长度为N的有序数组,寻找第N和N+1的数,有没有什么更快的办法 #
func findN(a, b []int, n int) (v1, v2 int) {
i, j := 0, 0
count := 0
// 合并两个有序数组,找到第n和n+1个数
for i < len(a) && j < len(b) {
count++
var current int
if a[i] <= b[j] {
current = a[i]
i++
} else {
current = b[j]
j++
}
if count == n {
v1 = current
}
if count == n+1 {
v2 = current
return v1, v2
}
}
// 处理剩余的元素
for i < len(a) {
count++
if count == n {
v1 = a[i]
}
if count == n+1 {
v2 = a[i]
return v1, v2
}
i++
}
for j < len(b) {
count++
if count == n {
v1 = b[j]
}
if count == n+1 {
v2 = b[j]
return v1, v2
}
j++
}
return v1, v2
}
迭代二分法
基础问题 #
Go 里的 map 是并发安全的吗?如果不是,有什么解决方案?它们各自的优缺点和适用场景是什么? #
- 完美答案思路:首先明确回答不是。然后给出解决方案:1) sync.Mutex 或 sync.RWMutex 加锁;2) sync.Map。接着深入对比:sync.Mutex 简单直接,但在锁竞争激烈时性能下降明显,因为所有 goroutine 都会阻塞。sync.RWMutex 读写锁分离,适合读多写少的场景。sync.Map 是官方为特定场景(读多写少、key 相对稳定)设计的,通过空间换时间、读写分离(read map 和 dirty map)等机制,实现了分段锁和原子操作,避免了全局加锁的开销,性能更好。但是它的接口不是标准 map 接口,且在写多读少的场景下,由于 dirty map 升级等操作,性能可能不如互斥锁。最后可以结合项目说:“在我们的 Master 服务中,管理插件状态的 map 就是一个典型的读多写少场景,我们就使用了 sync.Map 来提升性能。”
能聊聊你对 Go 的 GMP 调度模型的理解吗?它相比其他语言的线程模型有什么优势? #
- 完美答案思路:清晰地解释 G (Goroutine), M (Machine/Thread), P (Processor) 的角色和关系。G 是轻量级用户态线程,M 是内核线程,P 是调度上下文,连接 G 和 M。核心优势:1) 轻量:Goroutine 栈空间初始很小(2KB),创建销毁开销远小于线程。2) 高效调度:通过 P,实现了用户态调度,避免了频繁的内核态/用户态切换。P 内部有本地 G 队列,M 会优先执行本地队列的 G。3) Work-Stealing:当一个 P 的本地队列空了,它可以从其他 P 的队列或者全局队列“偷”任务,保证 M 不会闲置,提升了 CPU 利用率。4) 系统调用处理:当一个 G 发生系统调用阻塞时,调度器会将 M 和这个 G 分离,并让 P 去绑定一个新的 M 继续执行其他 G,避免整个线程被阻塞。这些特性使得 Go 非常适合开发 I/O 密集型的高并发服务。
gRPC 和 WebSocket,它们都支持双向通信。能谈谈它们在技术实现、性能和适用场景上的区别吗?你在项目中为什么做了这样的技术选型? #
- 完美答案思路:
- 技术实现:gRPC 基于 HTTP/2,默认使用 Protocol Buffers 进行序列化,支持四种通信模式(一元、服务端流、客户端流、双向流),提供强类型、跨语言的 IDL 定义。WebSocket 是一个独立的协议,基于 TCP,通过 HTTP/1.1 的 Upgrade 协议头建立连接,连接一旦建立就是全双工的 TCP 通道,传输的数据格式不限(通常是 JSON 或二进制)。
- 性能:gRPC 因为 HTTP/2 的多路复用和头部压缩,以及 Protobuf 高效的二进制序列化,通常在服务间通信(特别是内网)性能更高。WebSocket 建立连接后开销小,但数据本身没有 gRPC 那么高效。
- 场景:gRPC 非常适合微服务间的内部通信,因为它有严格的契约(IDL),性能高,生态好。WebSocket 更适合浏览器与服务器之间的长连接实时通信,比如实时消息、进度更新、在线游戏等。
- 项目选型:“在‘觅影’项目中,Master 服务与底层的视频处理引擎(觅影.exe)之间,我们选择了 gRPC。因为这是后端服务间的调用,我们需要高性能和严格的接口定义来保证稳定性,gRPC 是不二之选。而 Master 服务与前端 UI 之间的进度条更新、结果预览,我们用了 WebSocket,因为它能很好地被浏览器支持,实现实时的、低延迟的推送。”
你熟悉 CI/CD 流水线。能描述一下你们项目的典型 CI/CD 流程吗?从代码提交到部署,都经历了哪些阶段? #
- 完美答案思路:以 GitLab CI 为例。开发者提交代码到 feature 分支 -> 触发 CI Pipeline -> Build 阶段:go build 编译 Go 程序,docker build 构建 Docker 镜像 -> Test 阶段:go test -race ./… 运行单元测试和竞争检测,go vet 和 golangci-lint 进行静态代码检查 -> Merge Request:测试通过后,提交 MR 到主分支,由同事 Code Review -> Deploy to Staging:合并到主分支后,自动触发部署到预发/测试环境 -> Manual Deploy to Production:经过QA测试后,手动点击按钮,触发部署到生产环境。整个流程定义在项目根目录的 .gitlab-ci.yml 文件中。
在什么场景下你会优先考虑使用 sync.Map 而不是 sync.Mutex 或 sync.RWMutex?sync.Map 的实现原理是什么?它在哪些情况下会比互斥锁差? #
- 完美答案思路:
- 优先场景:当一个数据结构有大量读操作和较少写操作,并且写操作之间可能存在 key 的重叠(即不同的 goroutine 修改相同的 key),或者并发写入同一个 map 的情况较少时,sync.Map 会有优势。例如,缓存、配置信息、用户会话等。
- 实现原理:sync.Map 内部主要由两部分组成:read 和 dirty。read 是一个只读的 map,用于快速读取;dirty 是一个可写的 map,用于处理写入操作。当发生写入时,会先尝试写入 dirty map。如果 dirty map 已经存在(表示有写操作发生),并且写入的是 read map 中已存在的 key,那么 read map 的当前副本会被提升到 dirty map 中,同时将 read 升级为只读副本。当读操作遇到一个 key 存在于 dirty map 中时,它会先从 dirty map 中读取,如果不在,则从 read map 中读取。这种机制通过分离读写、采用分段锁(针对 dirty map 的部分)和原子操作(如 atomic.LoadPointer)来减少锁竞争。
- 劣势情况:
- 写多读少:如果写入操作非常频繁,dirty map 的升级(复制 read map 到 dirty)开销会非常大,性能可能不如直接使用 sync.RWMutex。
- Key 的稳定性:如果 Key 的生命周期很短,频繁地从 read map 迁移到 dirty map,也会产生不必要的开销。
- 遍历开销:sync.Map 的 Range 方法需要遍历 read 和 dirty 两个 map,并在遍历过程中可能发生竞争,效率不如普通 map 加上 sync.Mutex。
- 接口不友好:sync.Map 没有 len() 方法,也没有支持 delete(需要特殊标记)等,使用起来不如标准 map 方便。
项目问题 #
我们来聊聊你“牵头完成的架构设计”。请画一下这个系统的架构图,并解释每个组件的职责。当初为什么选择“微内核主从式混合架构”?有没有考虑过其他方案,比如纯粹的微服务架构?你们的方案优势和劣C势分别是什么? #
- 完美答案思路:(这是对简历“吹牛”点的直接攻击,必须准备得非常充分)
- 画图 & 解释:画出 Master.exe, Server.exe (多个), Plugin.exe (多个) 的关系图。Master 负责接收前端请求、任务分解、状态管理、DB 交互。Server.exe 是资源封装层,比如“视频处理 Server”独占 GPU,提供 gRPC 接口。Plugin.exe 是业务逻辑层,如“扫描插件”、“导出插件”,它们是无状态的,可以水平扩展,通过 gRPC 调用 Master 和 Server。
- 选型理由:解释业务痛点:1) 视频处理、AI 计算是资源密集型任务,必须与主业务逻辑隔离,防止一个任务崩溃导致整个系统宕机。2) 取证业务逻辑多变,需要快速开发和部署新功能(新视频格式、新 AI 模型),插件化是最好的方式。
- 方案对比:纯微服务架构:对于这个桌面端工具来说,服务发现、部署、运维成本太高。单体架构:完全不可取,资源隔离和模块化都做不到。当前架构(混合式):优势是兼顾了隔离性(进程级隔离)、扩展性(插件化)和部署简易性(几个 exe 文件即可)。劣势是进程间通信(gRPC)相比于函数调用有性能损耗,且整体架构的复杂度比单体高。
你说“异常时自动重启从属进程”。具体是怎么实现的?Master 如何感知到从属进程的存活状态?重启后,任务的状态如何恢复? #
- 完美答案思路:实现方式可以有很多。可以说:1) 心跳机制:从属进程定时通过 gRPC 或其他方式向 Master 发送心跳包,Master 维护一个时间戳,超时未收到则认为其宕机。2) OS 层面:Master 启动子进程时会获得其进程句柄 (Handle/PID),可以定期检查进程是否存在。具体实现上,我们结合了两者。心跳用于应用层面的健康检查,OS 进程检查作为兜底。任务恢复方面,Master 会持久化每个任务的状态和进度到 SQLite 数据库。当一个插件进程重启后,Master 会根据任务 ID 从数据库加载上下文,并指令插件从上次的断点继续执行。
你对接和部署了 YOLOv8、SwinIR 等多种 AI 模型。请具体描述一下这个流程。Python 模型是如何被 Go 程序调用的?性能如何?有没有遇到什么坑? #
- 完美答案思路:流程:1) 首先,我们有一个专门的 Python 服务,使用 FastAPI 或 Flask 框架。2) 这个 Python 服务负责加载 AI 模型(如 YOLOv8),并将其封装成一个简单的 HTTP/gRPC 接口,比如输入图片、输出识别结果。3) Go 写的“分析插件”通过 HTTP 客户端或 gRPC 客户端来调用这个 Python 服务。为了性能,我们将这个 Python 服务和 Go 插件部署在同一台机器上,以减少网络延迟。
- 性能与坑:最大的坑是性能。Go 和 Python 之间的数据序列化/反序列化(特别是大体积的图片/视频帧)开销很大。我们通过共享内存或者使用更高效的序列化协议(如 Apache Arrow)进行优化。另外,Python 的 GIL (全局解释器锁) 也是一个问题,对于需要并行处理多个请求的场景,我们通过多进程(如 Gunicorn)来部署 Python 服务,绕开 GIL 的限制。简历中提到的 CGO 也是一种方案,但维护成本高,我们主要用在对性能要求极致且库稳定的 C/C++底层库封装上。
你说“通过 WebSocket 推送镜像制作进度及剩余时间估算”。这个剩余时间是如何估算的?它会遇到什么问题导致不准? #
- 完美答案思路:估算方法:用一个简单的移动平均算法。记录最近 N 次读取操作的耗时和数据量,计算出平均速度(MB/s)。然后用(总磁盘大小 - 已完成大小)/ 平均速度,得到剩余时间。不准的原因:1) 磁盘读取速度不均匀:磁盘外圈速度快,内圈慢。2) 系统负载:其他程序也在读写磁盘,会干扰我们的速度。3) 坏道:遇到坏道时,读取会变得极慢,甚至超时,导致估算时间急剧增加。我们的策略是,在界面上提示“估算时间”,并动态调整,同时对坏道等异常情况有专门的处理逻辑和提示。
有 SQLite3 数据库优化经验,分享一下你做了哪些优化吗? #
- 完美答案思路:不要只说“加索引”。可以说:1) 调整 PRAGMA 参数:比如设置 PRAGMA journal_mode=WAL 开启预写日志模式,它能极大提高并发读写的性能。设置 PRAGMA synchronous=NORMAL 在保证基本安全性的前提下减少磁盘同步次数。2) 批量事务:对于大量的写入操作,我们会把它们包裹在一个大的事务(Transaction)里,而不是每次写入都自动提交,这能将成千上万次磁盘 I/O 减少到几次。3) 索引优化:使用 EXPLAIN QUERY PLAN 分析慢查询,为 WHERE 和 ORDER BY 子句中频繁使用的列创建合适的索引。4) 预编译语句:对于重复执行的 SQL,使用 sql.Stmt 进行预编译,避免了反复解析 SQL 语句的开销。
你是如何将 C/C++ 的底层库封装成 Go 能调用的 SDK 的吗?你们是如何管理这个 SDK 的版本和更新的? #
- 完美答案思路:主要通过 CGO 技术实现。
- 封装方式:
- 编写 C/C++ 接口:在 C/C++ 代码中,定义一系列清晰的 C 风格函数接口,这些函数接受 C 类型的参数(如 char*, int, void*),并且返回 C 类型的值。这些函数是 Go 调用 C 的入口点。
- 使用 CGO:在 Go 文件中,通过 import “C” 语法,可以直接调用这些 C 函数。你需要写一些 CGO 指令来声明这些函数及其参数类型。
- 数据类型转换:Go 的数据类型(如 string, []byte)需要转换为 C 的对应类型(如 C.CString, *C.uchar)。在 C 中处理完后,再将 C 的结果(如 C.GoString)转换回 Go 的类型。这部分是容易出错的地方,需要特别注意内存管理。
- 内存管理:Go 的 GC 不会管理 C 堆上的内存。因此,在 CGO 调用中,所有 C/C++ 分配的内存(如 malloc)都需要在 Go 中显式地释放(通过 C.free 或 C 接口的释放函数)。
- SDK 版本管理:我们将这些封装好的 CGo 库打成一个内部的 Go 模块。当底层 C/C++ 库更新时,我们会重新编译 CGo 库,更新 Go 模块的版本号,并通知依赖该模块的服务进行更新。我们也会维护一个简单的文档说明,列出版本更新内容和兼容性。
- 封装方式:
在处理大规模日志数据或二进制文件时,你通常会选择哪些数据结构来提高检索效率?例如,如果需要快速查找某个时间段内的日志条目,你会怎么做? #
- 完美答案思路:
- 数据结构选择:
- 有序映射/平衡二叉搜索树:对于需要按时间段(有序键)检索的数据,map[time.Time]SomeData 结合 sort.Slice 手动排序,或者使用第三方库实现的有序 map(如 github.com/emirpasic/gods 的 TreeMap)是不错的选择。它们提供 O(log N) 的插入和查找效率。
- Skip List:如果需要更优化的插入和查找性能,且对内存开销敏感,Skip List 也是一个很好的选择。
- 倒排索引:如果需要按关键词检索日志内容,则需要构建倒排索引。Go 的 bleve 或自己实现一个简单的倒排索引,将关键词映射到其出现的文件和位置。
- 快速查找时间段日志:
- 数据预处理:在加载日志数据时,就将其解析成结构体,并将时间戳作为有序的键存储在 TreeMap 或排序切片中。
- 二分查找:对于排序的切片或 TreeMap,可以使用二分查找(sort.Search)来快速定位到时间段的起始和结束位置,然后在此范围内进行遍历。
- 索引优化:如果日志量非常大,可能需要在不同的时间粒度(如按天、按小时)建立多级索引,进一步加快查找速度。
- 数据结构选择:
在 MyBatis/Hibernate 等 ORM 框架中,通常会涉及 N+1 查询问题。你在使用 GORM 时,是如何避免 N+1 查询的? #
- 完美答案思路:在 GORM 中,避免 N+1 查询的关键在于利用其 Eager Loading(预加载)功能,而不是 Lazy Loading(懒加载)。
- 预加载策略:当查询一个主对象(如用户 User)及其关联对象(如其订单 Orders)时,可以通过 Preload 方法来提前加载关联数据。例如:db.Preload(“Orders”).Find(&users)。这会在执行一次 SQL 查询时,通过 LEFT JOIN 或 IN 子句(GORM 会根据关联关系的特点选择最优方式)一次性查询出所有用户及其对应的订单,而不是先查用户,再为每个用户单独查询订单。
- 具体实践:在我们的项目中,当我们查询取证任务列表时,通常也需要关联查询任务的执行状态、关联文件等信息。我都会在第一次查询主数据时,通过 Preload 一次性加载所有需要用到的关联数据,确保查询效率。如果关联数据非常庞大,也可以只预加载部分关联字段。
