登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go bufio.ReadBytes 遇到读超时后怎样继续拼接一条消息

来源:17golang原创

时间:2026-11-05 09:58:55 390浏览 收藏

用 bufio.Reader.ReadBytes 读取一条以换行结束的长连接消息时,读期限可能先到,但消息的前半段已经读到了。这时不要因为 err != nil 就丢掉返回的字节:官方语义是,找不到分隔符时,ReadBytes 会把错误发生前读到的数据和错误一起返回。正确做法是先把 part 累加到消息缓冲区,确认错误属于可重试的超时后,给同一个连接设置新的读取期限,再继续调用同一个 bufio.Reader。

核心判断只有两层:返回数据始终先保存;只有 net.Error.Timeout() 为 true 的错误才进入有限次续读。读到分隔符代表消息完成,EOF 或其他错误代表这条消息不能继续按原样完成。

官方文档:https://pkg.go.dev/bufio#Reader.ReadBytes

先看清 ReadBytes 的两个返回值

ReadBytes(delim) 会读取到第一个分隔字节,并把分隔字节也放进返回切片。只有返回数据以分隔字节结尾时,错误才是 nil;如果在分隔字节出现前遇到 EOF、网络超时或其他底层错误,返回切片仍可能非空。

以换行消息为例,底层连接已经交付 order=42,但还没有交付最后的换行符。第一次读取可能得到:

data = []byte("order=42")
err  = 一个 Timeout() 为 true 的 net.Error

这不是“没有读到内容”,而是“当前调用拿到了未完成的前缀”。bufio.Reader 在内部先保存了这部分字节,再把底层错误交给上层;调用方如果直接返回,就会丢失一条尚未完成的消息。

net.Conn 设置读取期限后经过 bufio.Reader 调用 ReadBytes,超时同时带回部分消息并最终拼成完整消息的结构图
图1:ReadBytes 的部分数据和超时错误会一起回到调用方,这是续读的输入,不是截图或运行证据。

三种处理方式里,只有一种适合续读

在带读取期限的长连接中,常见做法有三种。第一种遇到任何错误都丢弃当前切片,代码简单,但会截断消息。第二种重新创建 bufio.Reader,看似清理了错误状态,却可能把旧 Reader 中还没消费的缓冲数据一起丢掉。第三种保留当前 Reader,把已经返回的片段写入独立的累加缓冲区,然后只对可恢复超时继续读取。

第三种方式的关键不是“忽略错误”,而是把“数据是否完整”和“本次读取是否暂时结束”拆开判断:

  • 切片最后是 \n 且错误为 nil:消息完整,可以交付。
  • 切片非空且错误是超时:先保存切片,再调整 deadline,回到同一个 Reader 继续读。
  • 错误是 io.EOF 或其他非超时错误:保存已读前缀,但不要把它伪装成完整消息。

用同一个 Reader 把超时片段拼回来

下面的函数以换行符作为消息边界。它同时设置消息字节上限和超时次数上限,避免对一条永远不发结束符的慢消息无限等待或无限占用内存。

package framed

import (
    "bufio"
    "bytes"
    "errors"
    "fmt"
    "io"
    "net"
    "time"
)

// readMessage 保留超时前拿到的片段,并在同一连接上续读换行消息。
func readMessage(conn net.Conn, maxBytes, maxTimeouts int, wait time.Duration) ([]byte, error) {
    reader := bufio.NewReader(conn)
    var assembled bytes.Buffer
    timeoutCount := 0

    for {
        // ReadBytes 即使返回错误,也可能带回分隔符前的有效数据。
        part, err := reader.ReadBytes('\n')
        if len(part) > 0 {
            if assembled.Len()+len(part) > maxBytes {
                // 消息超过上限时立即停止,避免慢连接造成无界增长。
                return append([]byte(nil), assembled.Bytes()...), fmt.Errorf("message exceeds %d bytes", maxBytes)
            }
            // bytes.Buffer 保存独立副本,下一次读取不会覆盖已拼接内容。
            _, _ = assembled.Write(part)
        }

        if err == nil {
            // ReadBytes 在无错误返回时已经找到分隔符,消息可以交付。
            return assembled.Bytes(), nil
        }

        var netErr net.Error
        if errors.As(err, &netErr) && netErr.Timeout() {
            timeoutCount++
            if timeoutCount > maxTimeouts {
                // 连续超时超过预算时,把已读前缀和真实错误一起交给调用方。
                return append([]byte(nil), assembled.Bytes()...), err
            }
            // deadline 是绝对时间;续读前必须设置一个新的未来时间点。
            if err := conn.SetReadDeadline(time.Now().Add(wait)); err != nil {
                return append([]byte(nil), assembled.Bytes()...), err
            }
            // 不重建 reader,继续消费同一个缓冲读取器的状态。
            continue
        }

        if errors.Is(err, io.EOF) {
            // EOF 前没有分隔符,只能返回不完整前缀和 UnexpectedEOF。
            return append([]byte(nil), assembled.Bytes()...), io.ErrUnexpectedEOF
        }
        // 非超时错误不具备本例中的续读条件,原样保留给上层处理。
        return append([]byte(nil), assembled.Bytes()...), err
    }
}

这里的 part 先于错误分支写入 assembled,这是整个续读逻辑最重要的顺序。超时分支只改变下一次读取的时间预算,不改变消息已经累积的内容,也不重新创建 Reader。

为什么 deadline 更新后可以继续使用同一个 Reader

bufio.Reader 负责保存从底层 Reader 取回的字节,连接的 deadline 则由 net.Conn 控制后续 I/O 等待时间。二者是不同层次的状态:超时错误被本次 ReadBytes 返回后,下一轮可以重新调用底层连接;只要没有业务上主动丢弃 Reader,就不必重建缓冲层。

Go 的 bufio 源码会先把底层 Read 返回的字节计入缓冲区,再记录错误。随后 ReadSlice 会把当前缓冲中的片段和错误返回给 ReadBytes。这正是“超时和部分数据同时出现”的原因,也是为什么不能只看错误而忽略切片。

需要注意,SetReadDeadline 设置的是一个绝对时间点,不是“从每次读取开始计时的固定秒数”。所以每次决定续读,都要设置一个新的未来 deadline;如果 deadline 已经过去,下一次调用仍会立即超时。

超时、EOF 和消息上限要分开

返回情况是否保留 data是否续读应用层结论
err 为 nil,data 以分隔符结尾是否交付完整消息
net.Error 且 Timeout() 为 true是有限次延长 deadline 后继续
io.EOF,data 没有分隔符是否连接结束,消息不完整
其他错误是否按真实错误处理
累计长度超过上限可保留诊断前缀否拒绝无界消息

不要用“只要有 data 就重试”代替错误分类。磁盘读取、协议解析或连接关闭错误可能永远不会因为再次调用而恢复;把它们统一当成超时,会让调用方在错误连接上循环。

以累加缓冲区为中心的 Go 分隔消息续读决策图,展示 Timeout 重试、EOF 结束和消息上限保护
图2:续读策略应同时设置超时分支、EOF 结束条件和消息上限,这是静态操作示意图。

接入长连接时的落地清单

  1. 先决定消息分隔符,并明确分隔符是否属于交付内容;ReadBytes 会把它保留在返回切片中。
  2. 为一条消息设置最大字节数,不要让对端通过不发送分隔符来持续扩大缓冲区。
  3. 对超时设置最大续读次数或总等待预算,续读前重新计算 deadline。
  4. 保存每次返回的 data,再判断 error;不能因为错误非空就丢弃当前片段。
  5. EOF 和非超时错误直接结束当前消息;如果需要重连,应由更上层的连接生命周期逻辑决定。
  6. 记录累计字节数、超时次数和最终错误类型,这些信息比单独记录“读取失败”更容易定位慢消息。

几个容易混淆的问题

ReadBytes 返回超时后,下一次调用会从头读吗?

不会从应用层的消息头重新开始。已经返回给调用方的片段不会自动留在 ReadBytes 的返回值里,所以需要由调用方累加;同一个 Reader 未消费的底层状态仍应继续使用。

把读取期限设长一点能不能替代拼接?

不能。更长的 deadline 只能减少超时发生概率,不能改变 ReadBytes 在消息未完成时返回“部分数据加错误”的契约。网络抖动、慢发送和服务端停顿仍可能触发超时。

超时后能不能重新创建 bufio.Reader?

不建议。重建 Reader 会让旧缓冲层的未消费数据失去引用;如果确实要更换读取器,应先明确旧 Reader 已经没有要交付的字节,并由协议层重新定义消息边界。

没有固定分隔符时还适合用 ReadBytes 吗?

不适合。此时应改用长度前缀、固定长度或其他明确的帧协议;仅靠不断追加字节无法判断一条消息何时完成,也无法可靠设置消息上限。

参考源码:https://go.dev/src/bufio/bufio.go;网络错误接口:https://pkg.go.dev/net#Error;读取期限:https://pkg.go.dev/net#Conn.SetReadDeadline。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>