fix(handler): 修复 sendfile 高并发下的连接断开处理

- EPIPE/ECONNRESET 不再 fallback,直接返回错误避免响应混乱
- 正确处理 EAGAIN/EWOULDBLOCK socket 缓冲区满,等待重试
- EINTR 信号中断正确重试
- 改用 SetBodyStream 确保 HTTP 头先发送再 sendfile
- 添加重试限制(100次)防止无限循环

测试结果:0 错误/100000 并发请求

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-16 09:54:08 +08:00
parent 0c20c62b5c
commit d874f97765
2 changed files with 69 additions and 18 deletions

View File

@ -12,6 +12,7 @@ import (
"net"
"os"
"syscall"
"time"
"github.com/valyala/fasthttp"
)
@ -20,6 +21,12 @@ const (
// MinSendfileSize 使用 sendfile 的最小文件大小8KB
// 小于该值的文件使用普通 io.Copy避免系统调用开销。
MinSendfileSize = 8 * 1024
// sendfile 最大重试次数
sendfileMaxRetries = 100
// sendfile 重试等待时间
sendfileRetryDelay = 1 * time.Millisecond
)
// SendFile 零拷贝文件传输。
@ -50,7 +57,12 @@ func SendFile(ctx *fasthttp.RequestCtx, file *os.File, offset, length int64) err
// Linux 平台使用 sendfile 系统调用
err := linuxSendfile(conn, file.Fd(), offset, length)
if err != nil {
// sendfile 失败fallback 到 io.Copy
// EPIPE/ECONNRESET 表示客户端已断开,不应 fallback
// 因为 HTTP 头可能已发送fallback 会造成响应混乱
if err == syscall.EPIPE || err == syscall.ECONNRESET {
return err // 直接返回错误,不 fallback
}
// 其他错误尝试 fallback 到 io.Copy
return copyFile(ctx, file, offset, length)
}
@ -82,6 +94,7 @@ func copyFile(ctx *fasthttp.RequestCtx, file *os.File, offset, length int64) err
// linuxSendfile Linux sendfile 系统调用。
//
// 使用 Linux 特有的 sendfile 系统调用实现零拷贝传输。
// 正确处理临时错误EAGAIN、EINTR和连接断开EPIPE、ECONNRESET
func linuxSendfile(conn net.Conn, fileFd uintptr, _, length int64) error {
socketFd, err := getSocketFd(conn)
if err != nil {
@ -91,15 +104,48 @@ func linuxSendfile(conn net.Conn, fileFd uintptr, _, length int64) error {
// Linux sendfile: sendfile(out_fd, in_fd, offset, count)
var sent int64
remain := length
retries := 0
for remain > 0 {
n, err := syscall.Sendfile(int(socketFd), int(fileFd), nil, int(remain))
if err != nil {
// 处理临时错误socket 缓冲区满,等待后重试
if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK {
retries++
if retries > sendfileMaxRetries {
// 超过最大重试次数,返回错误
return err
}
// socket 缓冲区满,短暂等待后重试
time.Sleep(sendfileRetryDelay)
continue
}
// 被信号中断,重试
if err == syscall.EINTR {
retries++
if retries > sendfileMaxRetries {
return err
}
continue
}
// 客户端断开连接,返回错误让 fasthttp 知道请求未完成
// 注意:不要返回 nil否则 fasthttp 会发送 200 + 空 body
if err == syscall.EPIPE || err == syscall.ECONNRESET {
return err // 返回错误,让 fasthttp 处理连接断开
}
// 其他错误直接返回
return err
}
if n == 0 {
break // EOF
break // EOF 或连接关闭
}
// 成功发送数据,重置重试计数
retries = 0
sent += int64(n)
remain -= int64(n)
}

View File

@ -41,15 +41,19 @@ import (
// - 大文件(>= 8KB自动启用零拷贝传输
// - alias 与 root 互斥,同时配置时 alias 优先
type StaticHandler struct {
fileCache *cache.FileCache
// 指针类型字段(按大小排列)
fileCache *cache.FileCache
gzipStatic *compression.GzipStatic
router *Router
// 字符串字段
root string
alias string
pathPrefix string
// 切片字段
index []string
tryFiles []string
// 基本类型字段
cacheTTL time.Duration // 缓存新鲜度 TTL默认 5s0 表示每次验证 ModTime
gzipStatic *compression.GzipStatic
router *Router
root string
alias string
pathPrefix string
index []string
tryFiles []string
useSendfile bool
tryFilesPass bool
symlinkCheck bool
@ -541,19 +545,20 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
}
// 大文件使用零拷贝传输
// 使用 fasthttp 的 SetBodyStream它会
// 1. 先写 HTTP 头到 bufio.Writer
// 2. Flush HTTP 头到 socket关键步骤
// 3. copyZeroAlloc → ReadFrom → sendfile
// 这样保证 HTTP 头先发送,避免顺序错乱导致的 "200 0" malformed response
if h.useSendfile && info.Size() >= MinSendfileSize {
// 设置 Content-Type (sendfile 不会自动设置)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
file, err := os.Open(filePath)
if err == nil {
defer func() {
_ = file.Close()
}()
if err := SendFile(ctx, file, 0, info.Size()); err == nil {
return
}
// sendfile 失败fallback 到 ServeFile
// SetBodyStream 会在 handler 返回后由 fasthttp 统一处理
// HTTP 头写入、Flush 和 sendfile 的顺序
ctx.Response.SetBodyStream(file, int(info.Size()))
return
}
}