runsisi's

technical notes

TCP 重传时间过长

2019-03-05 runsisi#debug#tcp

问题描述

网络故障时 TCP 重传会持续非常非常久(重传定时器从 200ms 开始,不断翻倍直至 120s),此时虽然 socket 无法进行数据传输,但 socket 并不会感知到任何异常而必须依赖应用层的心跳机制主动关闭 socket,在某些场景下,我们很忌讳这个 120s 的定时器,可能会导致网络恢复正常之后 socket 之间的通信仍然中断 120s。

解决方案

TCP 重传定时器的超时时间是一个公比为 2 的等比数列,最大超时时间为 TCP_RTO_MAX (120s),握手阶段的 tcp 重传处理与后两个阶段的处理有所差异。

参考如下代码(linux/net/ipv4/tcp_timer.c):

/* This function calculates a "timeout" which is equivalent to the timeout of a
 * TCP connection after "boundary" unsuccessful, exponentially backed-off
 * retransmissions with an initial RTO of TCP_RTO_MIN or TCP_TIMEOUT_INIT if
 * syn_set flag is set.
 */
static bool retransmits_timed_out(struct sock *sk,
				  unsigned int boundary,
				  unsigned int timeout,
				  bool syn_set)
{
	unsigned int linear_backoff_thresh, start_ts;
	unsigned int rto_base = syn_set ? TCP_TIMEOUT_INIT : TCP_RTO_MIN;

	if (!inet_csk(sk)->icsk_retransmits)
		return false;

	start_ts = tcp_sk(sk)->retrans_stamp;
	if (unlikely(!start_ts))
		start_ts = tcp_skb_timestamp(tcp_write_queue_head(sk));

	if (likely(timeout == 0)) {
		linear_backoff_thresh = ilog2(TCP_RTO_MAX/rto_base);

		if (boundary <= linear_backoff_thresh)
			timeout = ((2 << boundary) - 1) * rto_base;
		else
			timeout = ((2 << linear_backoff_thresh) - 1) * rto_base +
				(boundary - linear_backoff_thresh) * TCP_RTO_MAX;
	}
	return (tcp_time_stamp - start_ts) >= timeout;
}

注意该函数计算重传多次之后的累计超时时间(而不是指单次的重传超时时间),如果超过阈值,此时 TCP 必须断链(即向 socket 层报错)。

入参 boundary 指定最大重传次数,timeout 指定绝对超时时间,syn_set 指定当前是否处于握手阶段(即 sk->sk_state 处于 TCPF_SYN_SENT / TCPF_SYN_RECV 阶段)。

握手阶段

重传定时器超时基准时间(rto_base)为 TCP_TIMEOUT_INIT (1s),最大超时时间为 TCP_RTO_MAX (120s)。

boundary = icsk->icsk_syn_retries ? : sysctl_tcp_syn_retries;
timeout = 0;
syn_set = true;

其中 icsk->icsk_syn_retries 由 socket 选项 TCP_SYNCNT 控制,默认为 0; sysctl_tcp_syn_retries 由系统参数 net.ipv4.tcp_syn_retries 控制,默认为 TCP_SYN_RETRIES (6) 次。

在默认参数情况下,由于第 TCP_SYN_RETRIES 次重传的超时时间 1s * 2**TCP_SYN_RETRIES = 64s 仍然小于 TCP_RTO_MAX (120),因此根据等比数列求和公式:

1s * (1 + 2**1 + 2**2 + .. + 2**6) = 1s * (2**7 - 1) = 127s

得到总的超时时间为 127s。

综上,要实现这个阶段(即客户端 connect)的超时时间可控,可以设置 socket 选项 TCP_SYNCNT 或者系统参数 net.ipv4.tcp_syn_retries

非握手阶段

结束握手阶段之后,重传定时器的基准时间在如下函数调用中设置(注意区分服务端和客户端):

net/ipv4/tcp_input.c
tcp_rcv_state_process
  case TCP_SYN_SENT:
    tcp_rcv_synsent_state_process
      tcp_finish_connect
        tcp_set_state(sk, TCP_ESTABLISHED)
        tcp_init_metrics
  case TCP_SYN_RECV:
    tcp_set_state(sk, TCP_ESTABLISHED)
    tcp_synack_rtt_meas
      tcp_ack_update_rtt
        tcp_set_rto
    tcp_init_metrics

重传定时器超时基准时间(rto_base)为 TCP_RTO_MIN (0.2s),最大超时时间为 TCP_RTO_MAX (120s)。

boundary = sysctl_tcp_retries2;
timeout = icsk->icsk_user_timeout;
syn_set = false;

其中 sysctl_tcp_retries2 由系统参数 net.ipv4.tcp_retries2 控制,默认为 TCP_RETR2 (15) 次;icsk->icsk_user_timeout 由 socket 选项 TCP_USER_TIMEOUT 控制,默认为 0。

在默认参数情况下,由于第 10 次重传的超时时间 0.2s * 2**10 = 204.8s 已大于 TCP_RTO_MAX (120),因此只有前 9 次重传能使用等比数列求和公式:

0.2s * (1 + 2**1 + 2**2 + .. + 2**9) = 0.2s * (2**10 - 1) = 204.6s

再加上后 (TCP_RETR2 - 9) = (15 - 9) = 6 次的超时时间:

120s * 6 = 720s

得到总的超时时间为 204.6s + 720s = 924.6s

注意如果直接根据代码进行计算会得到超时时间为 1009.4s 的结果,实际测试发现第 1 次和 第 2 次重传的超时时间均为 0.2s,从第 3 次开始才倍增,重传次数会比 sysctl_tcp_retries2 控制的数目 + 1,总的超时时间控制在 928s 左右。

当设置系统参数 net.ipv4.tcp_retries2 为 11 时:

~# sysctl net.ipv4.tcp_retries2=11

重传次数为 11 + 1 = 12 次,总的超时时间为 446s 左右,与 retransmits_timed_out 的计算结果 529.4s 仍然差异比较大,但与理论值保持接近:

0.2s * (1 + 2**1 + 2**2 + .. + 2**9) + 0.2s + 120s * (11 - 9) = 0.2s * (2**10 - 1) + 0.2s + 240s = 444.8s

综上,要实现这个阶段(即客户端 connect)的超时时间可控,可以设置 socket 选项 TCP_USER_TIMEOUT 或者系统参数 net.ipv4.tcp_retries2,如果设置 TCP_USER_TIMEOUT,则总的超时时间完全由该参数控制,而不再受系统参数 net.ipv4.tcp_retries2 的控制。

注意

  1. 对于 TCP 选项而言,net.ipv4.tcp_* 选项同样适用于 IPv6[2];
  2. 丢弃 SYN 报文的 iptables 规则如下:
~# iptables -A INPUT -p tcp -m tcp --sport 8181 --tcp-flags SYN SYN -j DROP
  1. 注意 socket 选项 TCP_USER_TIMEOUTSO_KEEPALIVE 的差异,TCP_USER_TIMEOUT 控制重传超时时关闭 TCP 连接,而 SO_KEEPALIVE 控制闲时 TCP 链路的保活;
  2. 使用 ss 而不是 netstat 可以查看 TCP 定时器相关的信息,以及增加过滤条件。

参考资料

[1] TCP Keepalive is a lie

http://codearcana.com/posts/2015/08/28/tcp-keepalive-is-a-lie.html

[2] Linux tcp settings for ipv6

https://stackoverflow.com/questions/22157566/linux-tcp-settings-for-ipv6

[3] setsockopt, SO_KEEPALIVE and Heartbeats

https://holmeshe.me/network-essentials-setsockopt-SO_KEEPALIVE/

[4] Improving HA Failures with TCP Timeouts

http://greenstack.die.upm.es/2015/03/02/improving-ha-failures-with-tcp-timeouts/

[5] TCP_USER_TIMEOUT does not work when connection is stalled on zero-window probes

https://bugzilla.redhat.com/show_bug.cgi?id=1189241

附件

其中 drop-data4.pcap 是设置系统参数 net.ipv4.tcp_retries2 为 11 的抓包结果,其它附件都为使用系统默认参数的抓包结果。

drop-data.pcap

drop-data2.pcap

drop-data3.pcap

drop-data4.pcap

drop-syn.pcap

drop-syn2.pcap