本站的出站客户端
主线位置:客户端主线。请求 → 字节 → 响应这一段由 reqwest 交给 hyper-util 的 legacy
Client,后面就是本卷第 6 章讲的取连接、写请求、读响应。本页看 reqwest 在这条路上加了什么、没加什么,以及本站三处调用各自补了什么。
flowchart TB
oauth["OAuth:每次回调新建"] --> rq["reqwest::Client"]
pay["支付:每次请求新建"] --> rq
llm["LLM:进程内共用一个"] --> rq
rq --> tt["total timeout:Sleep"]
tt --> lc["legacy Client"]
lc --> pool["连接池:90 秒空闲"]
lc --> conn["reqwest Connector"]
conn --> px{"系统代理命中?"}
px -->|是| proxy["连到代理"]
px -->|否| hc["HttpConnector + TLS"]
本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 里 reqwest 只有一个版本 0.12.28,底下是 hyper 1.10.1、hyper-util 0.1.20。reqwest 的源码按「crate + 版本 + 文件」引用;hyper-util 的链接指向本卷的 0.1.21,本页引用的几处在 0.1.20 里行为相同(差别见最后一节)。
三处调用
| 调用方 | 代码 | Client 活多久 | 超时 |
|---|---|---|---|
| OAuth 回调:换 token、取用户信息 | handle_callback | 每次回调新建一个,换 token 和 fetch_profile 共用 | 连接 5 秒,总共 15 秒 |
| 支付网关:下单、查单、退款 | payment/src/host.rs 的 send | 每个请求新建一个 | 连接 5 秒,总共 15 秒 |
| 内容审核的 LLM | OpenAiChat::new、AnthropicChat::new | 构造一次,存在进程级的审核流水线 SHARED 里,管理员重载配置时才换 | 总共 30 秒(DEFAULT_TIMEOUT_SECS),没设连接超时 |
支付那一处:
async fn send(pr: &PayRequest, label: &str) -> Result<String, PayError> {
// 必须有超时:对账循环逐单 await,一个挂住的连接会让对账永久停住(Y5)。
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| PayError::Gateway(format!("创建 HTTP 客户端失败: {e}")))?;
// ...
三处都设了超时,原因在下面两节:hyper 这一层不给单个请求计时,reqwest 默认也不计时。三处里有两处每次新建 Client,后果在再下一节。
reqwest 给 legacy Client 配了什么
reqwest 0.12.28 的 src/async_impl/client.rs,ClientBuilder::build 里构造 hyper-util 的 Client:
let mut builder =
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new());
// ...
builder.timer(hyper_util::rt::TokioTimer::new());
builder.pool_timer(hyper_util::rt::TokioTimer::new());
builder.pool_idle_timeout(config.pool_idle_timeout);
builder.pool_max_idle_per_host(config.pool_max_idle_per_host);
// ...
let hyper_client = builder.build(connector_builder.build(config.connector_layers));
省略的部分是按用户配置转发的 HTTP/2 窗口、keep-alive ping 和几个 HTTP/1 解析宽松选项,默认都不设。连同 reqwest 自己的默认值:
| 项 | reqwest 0.12.28 默认 | 落在哪一层 |
|---|---|---|
timer | TokioTimer | 只转给 h2_builder,供 HTTP/2 keep-alive ping 用 |
pool_timer | TokioTimer | 连接池的后台清理任务要有它才启动,见 连接池 |
pool_idle_timeout | 90 秒 | 同上;和 hyper-util 的默认值一样 |
pool_max_idle_per_host | usize::MAX | 同上 |
| 连接器 | reqwest 自己的 Connector,里面是 HttpConnector<DynResolver> 加 TLS | 用 build 而不是 build_http,所以不走 hyper-util 给 HttpConnector 设 keepalive 的那段 |
| TCP | nodelay 开;TCP keepalive 15 秒、间隔 15 秒、重试 3 次;Linux 上 TCP_USER_TIMEOUT 30 秒 | 连接器 |
| 代理 | auto_sys_proxy: true:读环境变量和系统设置 | 连接器,见下文 |
connect_timeout | None | 连接器 |
timeout、read_timeout | None | reqwest 自己,在 hyper 外面 |
和直接用 hyper-util 的默认 Builder 比,最大的差别是 pool_timer:默认 Builder 没有它,过期的空闲连接只在下一次取连接时才丢掉;reqwest 的池子会按 max(90 秒, 90 ms) 的间隔在后台清理。
超时只在 reqwest 那一层
hyper 的客户端没有读超时。client::conn::http1::Builder 没有任何超时选项;legacy Client 的 timer 文档写的是「Provide a timer to be used for h2」,pool_timer 是「Provide a timer to be used for timeouts and intervals in connection pools」。一个请求发出去以后,对端不回,ResponseFuture 就一直 Pending。
reqwest 的三个超时都在 hyper 外面:
timeout:文档写「applied from when the request starts connecting until the response body has finished」。实现是一个 tokioSleep,等响应头时和请求 future 一起 poll,拿到响应后再包进 body(TotalTimeoutBody),读 body 时继续计时。read_timeout:每次读,读到数据就重置。本站没用。connect_timeout:reqwest 的Connector::call把整个连接过程包进with_timeout,DNS、TCP、TLS 握手、经代理的连接都算在内;同一个值也设给了内层HttpConnector。
下面用一个只收请求、永远不回的上游对比有没有 timeout:
reqwest = { version = "=0.12.28", default-features = false }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "time", "io-util"] }
use std::time::{Duration, Instant};
use tokio::{io::AsyncReadExt, net::TcpListener};
#[tokio::main]
async fn main() {
// 一个挂住的上游:收下请求,永远不回
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
tokio::spawn(async move {
loop {
let (mut s, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf).await;
std::future::pending::<()>().await;
drop(s);
});
}
});
let plain = reqwest::Client::builder().no_proxy().build().unwrap();
let timed = reqwest::Client::builder()
.no_proxy()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_millis(500))
.build()
.unwrap();
let t0 = Instant::now();
match tokio::time::timeout(Duration::from_secs(3), plain.get(&url).send()).await {
Ok(r) => println!("no timeout: {:?}", r.map(|r| r.status())),
Err(_) => println!("no timeout: still pending after {}ms", t0.elapsed().as_millis()),
}
let t0 = Instant::now();
match timed.get(&url).send().await {
Ok(r) => println!("timeout(500ms): {}", r.status()),
Err(e) => println!(
"timeout(500ms): after {}ms: {e} (is_timeout={})",
t0.elapsed().as_millis(),
e.is_timeout()
),
}
}
两个客户端都用了 .no_proxy(),原因见后文。M2 Max、rustc 1.99.0、--release,连跑 3 次,只有端口号和毫秒数(501–504 ms)不同:
no timeout: still pending after 3003ms
timeout(500ms): after 503ms: error sending request for url (http://127.0.0.1:59935/) (is_timeout=true)
没有 timeout 的请求在 3 秒时还挂着,是外面套的 tokio::time::timeout 把它放弃的;不套,它会一直挂下去。
问: reqwest 明明给 hyper 的 Client 设了 timer,为什么请求还是不限时?
答: 那个 timer 不是给请求用的。legacy Client::timer 只交给 HTTP/2 的 Builder,用来发 keep-alive ping;pool_timer 只用来清理空闲连接。hyper 没有「等响应最多多久」这个选项,「这个请求最多等多久」只能由调用方在外面套一个 Sleep,reqwest 的 timeout 就是这么做的。服务端的 header_read_timeout 也一样要 timer 才生效,但那是另一个方向的事,见 Executor 与 Timer。
本站这几个数字的来历,写在提交 322b85b「fix(http): time out payment gateway and OAuth requests (Y5)」的说明里:
reqwest has no total timeout by default. The reconcile loop awaits each order's gateway query in turn, so one stalled connection stopped reconciliation for the life of the process without a log line; checkout and login could hang the same way. 5s connect / 15s total.
支付对账是 main.rs 里一个 tokio::spawn 出去的循环,每轮逐单 await 网关查询。其中一单的连接挂住,整个循环就停在那个 await 上,后面的订单再也不会被查。登录和下单是用户请求里的一步,挂住就是浏览器一直转圈。LLM 那一处的 30 秒,注释写的是「LLM 调用普遍较慢,但单次至多 30s;超过则上游有问题」。
每次新建 Client:连接池用不上
OAuth 和支付每次调用都 Client::builder()...build()。reqwest 的文档说 Client 内部持有连接池,「it is advised that you create one and reuse it」,而且它内部已经是 Arc,复制一份很便宜。每次新建,池子也是新的,用完就跟着 Client 一起 drop。
下面这个程序用和支付一样的配置,对一个会打印连接开关的 hyper 服务端各发三次请求:一次共用一个 Client,一次每次新建。
reqwest = { version = "=0.12.28", default-features = false }
hyper = { version = "=1.12.0", features = ["server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "time"] }
use std::{
convert::Infallible,
sync::atomic::{AtomicUsize, Ordering},
time::{Duration, Instant},
};
use hyper::{Request, Response, body::Incoming, server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
static CONNS: AtomicUsize = AtomicUsize::new(0);
async fn server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
let t0 = Instant::now();
tokio::spawn(async move {
loop {
let (s, _) = listener.accept().await.unwrap();
let n = CONNS.fetch_add(1, Ordering::SeqCst) + 1;
println!(" {:>4}ms server: conn #{n} open", t0.elapsed().as_millis());
tokio::spawn(async move {
let svc = service_fn(|_req: Request<Incoming>| async { Ok::<_, Infallible>(Response::new("ok".to_string())) });
let _ = http1::Builder::new().serve_connection(TokioIo::new(s), svc).await;
println!(" {:>4}ms server: conn #{n} closed", t0.elapsed().as_millis());
});
}
});
url
}
fn client() -> reqwest::Client {
reqwest::Client::builder()
.no_proxy()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.build()
.unwrap()
}
#[tokio::main]
async fn main() {
println!("one shared Client (like crates/llm):");
let url = server().await;
let shared = client();
for _ in 0..3 {
shared.get(&url).send().await.unwrap().text().await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
}
drop(shared);
tokio::time::sleep(Duration::from_millis(100)).await;
println!("a new Client per call (like OAuth / payment host.rs):");
CONNS.store(0, Ordering::SeqCst);
let url = server().await;
for _ in 0..3 {
let c = client();
c.get(&url).send().await.unwrap().text().await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
每次请求之间隔 100 ms,远小于 90 秒的空闲超时。第一组三次请求共用一个 Client,最后 drop(shared);第二组每次循环新建一个 c,循环体结束时 drop。服务端的 open / closed 会怎么排?
在本站,这件事的代价是每次调用都重新做 DNS、TCP 和 TLS 握手:
- OAuth 一次回调发两个请求。GitHub(
github.com和api.github.com)、Google 的两个地址不在同一个主机,连接池按 (scheme, authority) 分组,本来也复用不上;Discord、X 两个地址同主机,两个请求又共用同一个Client,第一个响应读完、连接回池后,第二个请求才有机会复用它。 - 支付每个请求一个
Client,下单、查单、退款都是新连接;对账循环一轮查多少单,就握多少次手。 - LLM 的
Client活得长,连接能复用,pool_timer也真的在后台跑。
改成共用的形状很简单:一个 static CLIENT: OnceLock<reqwest::Client>,按现在的超时配置构造一次,调用处 .clone() 或直接借用。超时是 Client 上的配置,共用不影响它。改不改由站点决定;登录和支付的调用频率很低,多出来的握手不大可能成为瓶颈。
测试为什么要 .no_proxy()
reqwest 默认 auto_sys_proxy: true,构造 Client 时加一个 ProxyMatcher::system(),里面是 hyper-util 的 Matcher::from_system:先读环境变量 ALL_PROXY、HTTPS_PROXY、HTTP_PROXY、NO_PROXY(大小写两种),环境变量没给的,再在 macOS 上读系统设置。读系统设置的是 mac::with_system,它只取六个键:HTTPEnable、HTTPProxy、HTTPPort 和对应的三个 HTTPS 键。系统设置里的例外列表(ExceptionsList)和「不代理简单主机名」(ExcludeSimpleHostnames)都没读。排除规则只有 NO_PROXY 一个来源。
所以在一台开了系统代理、shell 之外又没设 NO_PROXY 的 Mac 上(比如从 IDE 里跑测试),发往 127.0.0.1 甚至 localhost 的请求也会交给代理。下面的程序只读配置、不发请求,看 from_system() 对三个地址的判断:
hyper-util = { version = "=0.1.21", features = ["client-proxy", "client-proxy-system"] }
use hyper_util::client::proxy::matcher::Matcher;
fn main() {
// 只读配置,不发任何请求:reqwest 0.12 默认用的就是 Matcher::from_system()
let m = Matcher::from_system();
for dst in ["http://127.0.0.1:8080/", "http://localhost:8080/", "https://api.github.com/"] {
let hit = m.intercept(&dst.parse().unwrap()).map(|i| i.uri().to_string());
println!("{dst:<26} -> {hit:?}");
}
}
测试用的 Mac 上,系统代理是 Clash 默认的 127.0.0.1:7890,系统例外列表里有 localhost;shell 里设了 HTTP_PROXY 和包含 127.0.0.1、localhost 的 NO_PROXY。先在 shell 里直接跑,再用 env -u 去掉全部 8 个代理环境变量跑。每种各跑 3 次,输出相同:
== shell env
http://127.0.0.1:8080/ -> None
http://localhost:8080/ -> None
https://api.github.com/ -> Some("http://127.0.0.1:7890/")
== no proxy env vars
http://127.0.0.1:8080/ -> Some("http://127.0.0.1:7890/")
http://localhost:8080/ -> Some("http://127.0.0.1:7890/")
https://api.github.com/ -> Some("http://127.0.0.1:7890/")
(两个 == 标题是运行时 echo 的。)有 NO_PROXY 时回环地址不走代理;没有环境变量时,连系统例外列表里明写的 localhost 也被交给了代理。
被交给代理以后会怎样,取决于代理。用一个本地的「黑洞代理」模拟最坏的情况:它收下请求、什么也不回就关连接。程序把 HTTP_PROXY 指向它,再分别用 Client::new() 和 .no_proxy() 访问一个本地 axum 服务:
reqwest = { version = "=0.12.28", default-features = false, features = ["system-proxy"] }
axum = "=0.8.9"
tokio = { version = "=1.54.1", features = ["rt-multi-thread", "net", "io-util"] }
use std::error::Error;
use axum::{Router, routing::get};
use tokio::{io::AsyncReadExt, net::TcpListener};
fn main() {
// 一个「代理」:收下请求、打印第一行、什么也不回就关连接
let proxy = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let proxy_url = format!("http://{}", proxy.local_addr().unwrap());
// 在任何线程启动之前设置;reqwest 构造 Client 时读环境变量
unsafe { std::env::set_var("HTTP_PROXY", &proxy_url) };
tokio::runtime::Runtime::new().unwrap().block_on(async move {
proxy.set_nonblocking(true).unwrap();
let proxy = TcpListener::from_std(proxy).unwrap();
tokio::spawn(async move {
loop {
let (mut s, _) = proxy.accept().await.unwrap();
let mut buf = [0u8; 1024];
let n = s.read(&mut buf).await.unwrap();
let line = String::from_utf8_lossy(&buf[..n]).lines().next().unwrap_or("").to_string();
println!(" proxy got: {line}");
drop(s);
}
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
tokio::spawn(async move {
axum::serve(listener, Router::new().route("/", get(|| async { "ok" }))).await.unwrap()
});
for (name, client) in [
("Client::new()", reqwest::Client::new()),
("builder().no_proxy()", reqwest::Client::builder().no_proxy().build().unwrap()),
] {
println!("{name}:");
match client.get(&url).send().await {
Ok(res) => println!(" {} {:?}", res.status(), res.text().await.unwrap()),
Err(e) => {
println!(" error: {e}");
let mut src = e.source();
while let Some(s) = src {
println!(" caused by: {s}");
src = s.source();
}
}
}
}
});
}
同样先用 env -u 去掉 shell 里的代理变量(否则 NO_PROXY 会放过 127.0.0.1)。连跑 3 次,只有端口号不同:
Client::new():
proxy got: GET http://127.0.0.1:59396/ HTTP/1.1
error: error sending request for url (http://127.0.0.1:59396/)
caused by: client error (SendRequest)
caused by: connection closed before message completed
builder().no_proxy():
200 OK "ok"
- 代理收到的请求行是绝对形式
GET http://127.0.0.1:59396/ HTTP/1.1:连接器报告这是代理连接,legacyClient就保留完整 URI,见 legacy Client。 - 错误链的最底层是 hyper 的
IncompleteMessage:连接在响应头之前被关。报错指着本地服务的 URL,真正关连接的却是代理。这个错误的一般成因见 connection closed before message completed。 .no_proxy()把auto_sys_proxy关掉,也清空代理列表,请求直连。
本站的测试都这么做。openai.rs 的注释写着约定的出处:
// 项目约定(CLAUDE.md::Rust HTTP Testing):测试场景需用 `.no_proxy()`
// 避免本地代理拦截 loopback;生产环境保持默认 client 行为。
// 这里在测试时由 [`Self::with_client`] 注入 `.no_proxy()` 的 client。
with_client 的文档说得更直接:「避免 macOS 系统代理把 127.0.0.1 转给 Clash 等代理黑洞」。OAuth 的 fetch_profile_rejects_error_status 测试也是 reqwest::Client::builder().no_proxy().build()。项目约定里还写着「localhost 不能替代 .no_proxy()」,实测原因就是上面那条:系统例外列表根本没被读。
生产代码不需要 .no_proxy()。容器是 Linux,from_system 在 Linux 上只看环境变量,docker-compose.yml 没给 app 设任何代理变量,请求直连。
hyper-util#189「feat(client): add macOS system proxy support for Matcher」,2025-05-05 合并,首次发布在 hyper-util 0.1.12。PR 描述只有一句:「The added code is mostly ported from reqwest::proxy.」在那之前,hyper-util 的 Matcher 只读环境变量,读 macOS 系统设置的代码在 reqwest 里;这之后 reqwest 0.12 的 src/proxy.rs 直接 use hyper_util::client::proxy::matcher,系统代理的判断交给 hyper-util。
搬过来的 with_system 就是上面说的那段:只取 HTTP / HTTPS 的开关、主机和端口,不读例外列表。所以本站测试里遇到的代理问题,根源在 hyper-util 的这个函数,表现在 reqwest 上。
本站锁的版本与本卷的差别
本站的 reqwest 0.12.28 解析到 hyper 1.10.1、hyper-util 0.1.20。legacy Client 的 client.rs 在 0.1.20 和 0.1.21 之间只多了几个 HTTP/2 选项的转发方法和 edition 2024 的写法;proxy/matcher.rs 的 macOS 部分没变(改动在 Windows 的通配符处理)。和客户端这条路有关的修复:
| 版本 | 条目 | 和本站的关系 |
|---|---|---|
| hyper-util 0.1.21 | #292 cancel the idle interval once its pool empties | reqwest 设了 pool_timer,后台清理任务会启动。0.1.20 里池子空了它也不停,每 90 秒醒一次,直到 Client 被 drop;本站只有 LLM 的 Client 活得长,影响就是这一个定时唤醒。机制见 连接池 |
| hyper-util 0.1.21 | #322 TokioExecutor 不再传播当前 tracing span | 本站没有直接开 hyper-util 的 tracing,但服务端构建(--features server)里 dioxus-server 0.7.9 依赖 hyper-util 时写了 features = ["full"],full 含 tracing,feature 合并后 reqwest 用的那份 hyper-util 也开着它(cargo tree -e features -i hyper-util 核实,ceffc19 的 Cargo.lock 仍是 0.1.20)。reqwest 0.12.28 用的是 TokioExecutor::new(),所以在 0.1.20 下,连接任务和池的清理任务带着发起请求时的 span:OAuth 回调的请求在 server::auth_callback 里,打开 LLM 审核时评论的请求在 server::post_comment 里。实测见 rt::tracing:0.1.20 下这个 span 要到 Client 被 drop 才关闭。OAuth 每次新建 Client,回调返回后很快就关了;LLM 的 Client 长期存在,第一条评论的 server::post_comment span 会一直开着,之后那个 Client 的连接任务、清理任务的事件都挂在它下面。本站的 subscriber 是 fmt,默认过滤级别 info,没开 FmtSpan::CLOSE,而这些事件是 debug / trace,所以默认配置下日志里看不出差别;RUST_LOG 调到 hyper_util=trace 时才会看到。升到 0.1.21 后不再继承 |
| hyper 1.11.1 | #4110 evict pooled conn on request-side Connection: close | 请求带 Connection: close 时,那条连接不再回池 |
| hyper 1.12.0 | #4208 withdraw a stale want when the client dispatcher takes a request | 修之前 is_ready() 可能在请求在途时为真,legacy 连接池会把正忙的连接借给下一个请求,后者排在整个响应后面,见 连接上的客户端。本站并发共用一个 Client 的只有 LLM |
这些都不改变本页的结论:单个请求不限时,要靠 reqwest 的 timeout;测试要 .no_proxy()。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次,只有毫秒数差一两毫秒,以下是其中一次:
one shared Client (like crates/llm):
1ms server: conn #1 open
311ms server: conn #1 closed
a new Client per call (like OAuth / payment host.rs):
0ms server: conn #1 open
102ms server: conn #1 closed
103ms server: conn #2 open
206ms server: conn #2 closed
206ms server: conn #3 open
309ms server: conn #3 closed
- 共用一个
Client:三次请求只有一条连接。每次请求完,连接回到池里,下一次取出来接着用。它在第 311 ms 关闭,就在drop(shared)之后(三轮各睡 100 ms),不是因为空闲超时:池子随Client一起 drop,池里的SendRequest没了,连接任务跟着结束。 - 每次新建:三条连接。每条都在约 100 ms 后关闭,也就是那一轮循环结束、
c被 drop 的时刻;下一轮的新Client有自己的空池子,只能重新连。
小结
- 本站的出站 HTTP 全走 reqwest 0.12.28,底下是 hyper-util 的 legacy
Client。reqwest 给它配了TokioExecutor、timer(只给 HTTP/2 ping)、pool_timer、90 秒空闲超时,以及自己的连接器(代理、TLS、TCP 选项)。 - hyper 的客户端没有请求超时,reqwest 的
timeout、read_timeout、connect_timeout默认都是None。OAuth 和支付设了连接 5 秒、总共 15 秒,LLM 总共 30 秒,起因是一个挂住的网关查询让对账循环永久停住。 - OAuth 和支付每次调用新建
Client,连接池用不上,每次都重新握手;LLM 的Client构造一次、长期复用。 - reqwest 默认读系统代理,hyper-util 的 macOS 实现只读开关、主机、端口,不读例外列表,排除只能靠
NO_PROXY。测试里不.no_proxy(),回环请求可能被代理拿走,最后以IncompleteMessage收场。 - 本站的 hyper 1.10.1 / hyper-util 0.1.20 和本卷版本在这条路径上行为基本相同;0.1.21 的 #292 让空池子的清理任务退出,#322 让
TokioExecutor不再把请求的 span 带进后台任务(本站经 dioxus-server 的full打开了tracing,但默认日志级别下看不出差别)。