#14 浏览器访问本地 HTTPS 网站偶发卡住的排查经验

2026-08-15

我用 Golang 开发了一个本地站点,跑在 Caddy 上(动态注册路径),并使用阿里云 DNS 将域名解析到本地 IP。

今天突然发现网页无法打开,浏览器一直处于 Pending 状态,而 curl 访问正常。关闭网络代理和浏览器插件、清理缓存后问题仍然存在。

怀疑是 DNS 解析的问题,使用 curl -vk --resolve d3v.cn:443:192.168.31.31 https://d3v.cn/markjour/ 访问也正常。

更奇怪的是,发现使用访客模式可以打开。

最后还发现,在关闭一些浏览器 Tab 后,普通模式也恢复正常。暂时推测可能与浏览器 HTTP/2 连接复用有关:

  1. 开机之后我访问相关页面没有成功打开,发现是服务都没有启动,然后启动 Caddy 和后台服务,并动态注册路径。
    可能是这个过程中,有 Tab 没有关闭,一直 Pending。
  2. Caddy 支持 HTTP/2,浏览器会通过 ALPN 协商 HTTP/2;HTTP/2 会在同一个连接上复用多个请求。
    如果底层 HTTP/2 连接本身出现异常,复用该连接的多个请求可能同时表现为 Pending。
    关闭 Tab 后,浏览器可能释放异常连接并重新建立连接,因此问题自动恢复。

以后再次遇到的时候再检查验证。


后续多次遇到这个本地网站访问异常问题,今天抽空继续排查。

发现同一台机器上的 Edge,不同 Profile 对 d3v.cn 的访问结果不同:一个可以正常打开,另一个持续 ERR_TIMED_OUT。

进一步查看 Edge 的网络信息发现( / ),异常 Profile 将 d3v.cn 解析为 ::1 和 192.168.31.31,而正常 Profile 解析为 127.0.0.1。随后检查本机 /etc/hosts,发现配置了:

127.0.0.1 localhost
127.0.0.1 eqr.hosts.d3v.cn eqr eqr6
::1       eqr.hosts.d3v.cn

同时公网 DNS 中 eqr.hosts.d3v.cn 也存在地址记录。因此,同一域名实际上同时存在本地 hosts 和公网 DNS 两套解析结果,Edge 不同 Profile 可能缓存或选择了不同地址。

这次基本确认问题核心在域名解析结果不一致,而不是 Caddy、TLS 或 HTTP/2。后续应避免让同一域名同时依赖 hosts 和公网 DNS 提供不同地址,或者至少确保两者解析结果一致。

#12 HTTP/2 的几个特性

2022-08-20
  • Server Push 服务器主动推送资源,客户端请求 A 的时候,服务器把 A 相关的资源 B, C, D 都一起推送给客户端。
  • Early Hints HTTP 103 状态码。客户端请求 A 的时候,

    1. 如果是普通情况,服务器返回 200 状态码,带上资源信息
    2. 如果应用上 Early Hints,服务器返回 103 状态码,带上需要资源信息(Link 头),然后是资源信息

    好处就是节省了浏览器解析 HTML 获取子资源信息的延迟。刚解析 103 头,就可以开始请求子资源了。
    就最近的几个月,主流浏览器开始提供支持。

  • Preload Critical Assets 就是 HTML link 头中的 rel="preload",提前加载文件,避免按照文件解析生成的调用链顺序来加载,而提升资源加载速度。

Chrome 将禁用 HTTP/2 服务器推送(Server Push)支持

谷歌博客显示,在 Chrome 106 和其他基于 Chromium 的浏览器的下个版本中,默认情况下将禁用对 HTTP/2 服务器推送(HTTP/2 Server Push)的支持。

HTTP/2 允许服务器在实际请求之前 “推送” 服务端可能需要的资源, HTTP/2 的 Server Push 特性解决了 HTTP/1.x 的无脑按顺序加载资源的问题,本意是提高网页的响应性能。

然而这功能逻辑本身就有问题,比如资源存放在单个业务服务器上,并行推送多个静态资源只会降低响应速度,性能不升反降。而对于前后端分离的业务来说,HTTP/2 本身就支持多路复用,server push 只能稍微降低浏览器解析 html 的时间,对现代浏览器来说性能提升可以忽略不计。

HTTP/2 时代也只有 1.25% 的 HTTP/2 站点使用了这个特性。在 HTTP/3 出来之后,该功能更是彻底被遗忘了,最新的分析中,网站对 HTTP/2 的支持率从 1.25% 下降到 0.7%。

替代方案

103 Early Hints  是 Server Push 的首选替代方案,它具有 Push 的许多优点,而缺点则少得多。与服务器推送资源不同,103 Early Hints 只向浏览器发送可能受益于请求的资源提示。浏览器可以控制它是否需要这些资源,比如浏览器已经在 HTTP 缓存中拥有这些资源,则无需额外加载。

预加载关键资源是另一种选择,它允许页面和浏览器一起工作,在页面加载的早期抢先加载关键资源。它不如 Server Push  或 Early Hints   快 —— 但它不会延迟关键页面资源,而另外两种解决方案都可能发生这种情况。

参考资料与拓展阅读

#11 HTTP 协议新提案:Query 方法

2022-04-15

令我惊讶的是,竟然还有人不是为了解决什么痛点问题,而想对一个使用如此广泛的基础协议做改动。
我敢打赌,绝对不可能通过。

https://www.ietf.org/archive/id/draft-ietf-httpbis-safe-method-w-body-02.html

这个新提案的主要理由是 URL 中不能包含太多数据(实现的限制),然后受 URL 编码的影响,效率下降。
作者认为应该设计一个支持 Body 的 GET 方法,那就是 Query。

提案讨论了相关的缓存问题。要求将 URL 和 Body 合并成 Key。

可以在这里跟踪相关进展:

#10 HTTP 超时相关的疑问

2022-02-25

今天发现一个奇怪的现象,相同的代码在 CentOS 7 服务器上发起 HTTP 请求 3 秒之后超时,报 “TimeoutError: [Errno 110] Connection timed out”。
在我本地就按我们的定义的超时时间 5 秒超时,报 “tornado.simple_httpclient.HTTPTimeoutError: Timeout while connecting”。

#9 Go HTTP 客户端

2022-01-29

原生

之前的文章:Golang HTTP 以及 HTML/XML 解析

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    resp, err := http.Get("https://www.baidu.com")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer resp.Body.Close()
    fmt.Printf("%#v\n", resp.Status)           // string, "200 OK"
    fmt.Printf("%#v\n", resp.StatusCode)       // int, 200
    fmt.Printf("%#v\n", resp.Header)           // http.Header, map[string][]string
    fmt.Printf("%#v\n", resp.Request)          // *http.Request
    fmt.Printf("%#v\n", resp.ContentLength)    // int64
    fmt.Printf("%#v\n", resp.TransferEncoding) // []string(nil)
    fmt.Printf("%#v\n", resp.Trailer)          // http.Header(nil)
    fmt.Printf("%#v\n", resp.Uncompressed)     // bool
    fmt.Printf("%#v\n", resp.TLS)              // *tls.ConnectionState
    fmt.Printf("%#v\n", resp.Body)             // *http.bodyEOFSignal => io.ReadCloser => io.Reader

    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

multipart

admin@victus:~$ cd /C/Program\ Files/Go/src/mime/multipart
nosch@victus:/C/Program Files/Go/src/mime/multipart$ grep -ER 'func.+\) [A-Z]\w+' .
./formdata.go:func (r *Reader) ReadForm(maxMemory int64) (*Form, error) {
./formdata.go:func (f *Form) RemoveAll() error {
./formdata.go:func (fh *FileHeader) Open() (File, error) {
./formdata.go:func (rc sectionReadCloser) Close() error {
./formdata_test.go:func testFile(t *testing.T, fh *FileHeader, efn, econtent string) File {
./formdata_test.go:func (r *failOnReadAfterErrorReader) Read(p []byte) (n int, err error) {
./multipart.go:func (p *Part) FormName() string {
./multipart.go:func (p *Part) FileName() string {
./multipart.go:func (r *stickyErrorReader) Read(p []byte) (n int, _ error) {
./multipart.go:func (p *Part) Read(d []byte) (n int, err error) {
./multipart.go:func (pr partReader) Read(d []byte) (int, error) {
./multipart.go:func (p *Part) Close() error {
./multipart.go:func (r *Reader) NextPart() (*Part, error) {
./multipart.go:func (r *Reader) NextRawPart() (*Part, error) {
./multipart_test.go:func (mr *maliciousReader) Read(b []byte) (n int, err error) {
./multipart_test.go:func (s *slowReader) Read(p []byte) (int, error) {
./multipart_test.go:func (s *sentinelReader) Read([]byte) (int, error) {
./writer.go:func (w *Writer) Boundary() string {
./writer.go:func (w *Writer) SetBoundary(boundary string) error {
./writer.go:func (w *Writer) FormDataContentType() string {
./writer.go:func (w *Writer) CreatePart(header textproto.MIMEHeader) (io.Writer, error) {
./writer.go:func (w *Writer) CreateFormFile(fieldname, filename string) (io.Writer, error) {
./writer.go:func (w *Writer) CreateFormField(fieldname string) (io.Writer, error) {
./writer.go:func (w *Writer) WriteField(fieldname, value string) error {
./writer.go:func (w *Writer) Close() error {
./writer.go:func (p *part) Write(d []byte) (n int, err error) {

第三方库

GitHub: http client stars:>1000

  1. go-resty/resty shields.io:github/stars shields.io:github/languages/code-size shields.io:github/commit-activity/w shields.io:github/license
    Simple HTTP and REST client library for Go
  2. parnurzeal/gorequest shields.io:github/stars shields.io:github/languages/code-size shields.io:github/commit-activity/w shields.io:github/license
    GoRequest -- Simplified HTTP client ( inspired by nodejs SuperAgent )
  3. gojek/heimdall shields.io:github/stars shields.io:github/languages/code-size shields.io:github/commit-activity/w shields.io:github/license
    An enhanced HTTP client for Go
  4. imroc/req shields.io:github/stars shields.io:github/languages/code-size shields.io:github/commit-activity/w shields.io:github/license
    Simplified Golang HTTP client library with Black Magic, Less Code and More Efficiency
  5. dghubble/sling shields.io:github/stars shields.io:github/languages/code-size shields.io:github/commit-activity/w shields.io:github/license
    A Go HTTP client library for creating and sending API requests
  6. hashicorp/go-retryablehttp shields.io:github/stars shields.io:github/languages/code-size shields.io:github/commit-activity/w shields.io:github/license
    Retryable HTTP client in Go

简单的了解:

  1. resty 看起来确实不错,链式调用,清晰明了,而且有不错的调试信息。
  2. gorequest 是在原生库上做了一点简单的封装,优化调用体验。有篇中文文档可以参考:gorequest中文文档(非官方)
    需要学习一下他的设计。官方文档说是借鉴 Node.js 的 SuperAgent。
  3. sling 也挺有特色的,使 API 变得结构化,调用变得像普通的 Go 函数一样。
  4. go-retryablehttp 在原生库上加了一个自动重试机制。
  5. heimdall, req, 简单一看,还看不出来有什么特别的地方。

#8 Urlencode

2020-04-19

URL / URI 的设计中规定允许的字符,如果出现了其他字符就需要转义,这套规则被称之为百分号编码(Percent-encoding)。

WWW 的设计中就使用了这套规则,比如 URL 地址、和表单提交(application/x-www-form-urlencoded)等场景,所以这个编码规则也被称之为 URL 编码(URL encoding)。
编程时,编码解码操作一般就叫 urlencode,urldecode。

编码的方式非常简单,就是把字节用 16 进制的方式表示,然后每个字节前面加一个百分号。

#7 Python 应用: 简易 HTTP 服务器

2018-05-07

Python2

在当前目录起 HTTP 服务,可以用于测试和临时性的文件下载服务。

# Default bind to 0.0.0.0:8000
python -m SimpleHTTPServer

# Maybe you want to use port 8080
python -m SimpleHTTPServer 8080

Python3

除了可以指定端口,还可以指定绑定地址、工作目录。

# Also bind to 0.0.0.0:8000
python -m http.server

python -m http.server -h
# usage: server.py [-h] [--cgi] [--bind ADDRESS] [--directory DIRECTORY] [port]
#
# positional arguments:
#   port                  Specify alternate port [default: 8000]
#
# optional arguments:
#   -h, --help            show this help message and exit
#   --cgi                 Run as CGI Server
#   --bind ADDRESS, -b ADDRESS
#                         Specify alternate bind address [default: all interfaces]
#   --directory DIRECTORY, -d DIRECTORY
#                         Specify alternative directory [default:current directory]

python -m http.server 9999
python -m http.server --bind=127.0.0.1
python -m http.server --bind=127.0.0.1 9999
python -m http.server -d ~/Pictures

#6 HTTP 缓存总结

2018-04-06

Your Cache Headers Could Probably be More Aggressive
您的缓存标头可能更具侵略性
https://macarthur.me/posts/more-aggressive-cache-headers

HTTP 缓存的核心并不复杂,本质上解决两个问题:

  1. 缓存还能不能直接使用?
  2. 不能直接使用时,资源有没有发生变化?

第一次请求资源后,浏览器可能保存完整的 HTTP Response(Headers + Body)。
之后再次请求时,先判断缓存的新鲜度。新鲜则直接使用;过期则向服务器验证。
资源未变化服务器返回 304,否则返回 200 + body。

flowchart TB
    s([准备发起请求]) --> hasCache{有缓存?}

    hasCache -- 否 --> request[发起请求] --> e([结束])
    hasCache -- 是 --> isCacheExpired{缓存过期?}

    isCacheExpired -- 否 --> readCache[读取缓存] --> e
    isCacheExpired -- 是 --> hasEtag{有 ETag?}

    hasEtag -- 是 --> addHeaderIfNoneMatch[添加 If-None-Match 头] --> request
    hasEtag -- 否 --> hasLastModified{有 Last-Modified?}

    hasLastModified -- 是 --> addHeaderIfModifiedSince[添加 If-Modified-Since 头] --> request
    hasLastModified -- 否 --> request

    request --> response{响应?}

    response -- 是 --> e
    response -- 否 --> readCache

一、HTTP 缓存相关 Header

现代 HTTP 缓存主要围绕以下 Header 工作:

Header 作用
Cache-Control 定义缓存策略和新鲜度
ETag 标识资源版本
Last-Modified 标识资源最后修改时间
If-None-Match 根据 ETag 进行条件请求
If-Modified-Since 根据修改时间进行条件请求
Expires HTTP/1.0 时代的过期时间
Pragma 历史兼容机制

其中最核心的是:

Cache-Control
ETag / Last-Modified
If-None-Match / If-Modified-Since

Expires 和 Pragma 主要用于历史兼容,现代应用优先使用 Cache-Control。

二、HTTP Header 常见问题

1. no-cache 是不是不缓存?

不是。
表示允许缓存,但使用前必须重新验证。
no-store 才表示禁止存储响应。

指令 允许存储 使用前验证
no-cache 是 是
no-store 否 不适用

2. max-age 控制浏览器还是 CDN?

都可能。

Cache-Control: public, max-age=60, s-maxage=3600

通常表示普通缓存的新鲜时间为 60 秒,而共享缓存(如 CDN)可以使用 s-maxage 的 3600 秒。

3. ETag 是不是文件 MD5?

不是。

ETag 只是服务器定义的实体标签,可以由 Hash、版本号等方式生成。

4. 304 是不是重新返回资源?

不是。

200 → 返回资源
304 → 资源没变化,继续使用已有缓存

304 不重新传输原来的 Response Body。

5. 浏览器刷新为什么还可能出现 304?

刷新不等于清空缓存。浏览器可能要求重新验证缓存,于是发送:

If-None-Match: "abc123"

服务器确认资源没有变化后返回 304 Not Modified。

具体行为还取决于浏览器、刷新方式以及开发者工具设置。

三、强缓存

“强缓存”不是 HTTP 标准术语,而是开发领域对缓存新鲜时直接使用这一行为的称呼。

例如:

Cache-Control: max-age=3600

第一次请求:

Browser → Server → 200 + Resource
                       ↓
                  Browser Cache

缓存仍然新鲜时:

Browser → Browser Cache

无需向服务器发送请求。

因此:强缓存解决的是“要不要发网络请求”。

需要注意,缓存过期并不意味着缓存立即被删除,而是通常不能再无条件使用,需要进入重新验证流程。

四、协商缓存与 304

缓存过期后,客户端可以利用 ETag 或 Last-Modified 发起条件请求。

  • ETag 可以理解成文件版本:

例如第一次请求服务器返回 ETag: "abc123"
后续的请求就可以带上 If-None-Match: "abc123"

服务器比对本地资源 ETag 就可以根据资源变化情况返回 304 或 200 + 新 ETag。

  • Last-Modified 是变更时间:
# 对应 ETag
Last-Modified: Tue, 08 Sep 2026 10:00:00 GMT

# 对应 If-None-Match
If-Modified-Since: Tue, 08 Sep 2026 10:00:00 GMT

总之,服务器端和浏览器根据这些头来协商缓存,确认资源有没有变化,以及是否需要重新传输 Body。

五、浏览器与 CDN 的多级缓存

生产环境通常不是:

Browser → Origin

而是:

Browser → CDN → Origin

甚至:

Browser → Proxy → CDN → Origin

浏览器缓存命中时,请求根本不会到达 CDN。

浏览器缓存过期后,请求到 CDN;如果 CDN 仍然有新鲜缓存,也无需回源。

因此每一层都可能独立判断:是否存在缓存?缓存是否 Fresh?是否需要重新验证?是否需要继续向下一层请求?

这也是理解 CDN 缓存的基础。

六、生产环境缓存策略

不同资源应该采用不同策略。

  • HTML:让浏览器保存,但使用前重新验证,及时获取最新入口:Cache-Control: no-cache
  • JS / CSS

    如果文件名带内容 Hash,比如 app.8f3a2c.js,可以长期缓存:

    Cache-Control: public, max-age=31536000, immutable
    

    内容变化后生成新的 URL,从根本上避免旧版本缓存问题。

  • 公共 API:根据实时性设置:Cache-Control: public, max-age=60

  • 用户私有数据:Cache-Control: private
    敏感数据则考虑:Cache-Control: no-store