然而我们观察到一个现象:由于这些长期存活的持久连接,API Server 之间的负载出现了不均衡。这会导致某些 API Server 承受不成比例的负载,并对系统可靠性产生级联影响。当我们对控制平面节点执行滚动更新时——这是更新控制平面 组件或修改配置时的常规操作——这种不均衡现象尤为明显。
为了解决客户端”粘性”问题,我们决定配置 Kubernetes API Server 的 –goaway-chance 参数。该参数在 k8s 1.18 中引入,是一个 API Server HTTP 过滤器,它以一定概率向 HTTP/2 客户端发送 RST_STREAM,强制客户端在新建立的 TCP 连接上重新发起请求。通过启用此设置,我们实现了显著改善的连接均衡和负载分发。
W1215 09:45:02.898142 1 reflector.go:441] k8s.io/client-go/informers/factory.go:134: watch of *v1.Lease ended with: an error on the server (“unable to decode an event from the watch stream: http2: client connection lost”) has prevented the request from succeeding
client connection lost 是什么意思,它又是如何导致 informer 超过 40 秒未收到任何更新的呢?
func readIdleTimeoutSeconds() int { ret := 30 if s := os.Getenv(“HTTP2_READ_IDLE_TIMEOUT_SECONDS”); len(s) > 0 { i, err := strconv.Atoi(s) if err != nil { klog.Warningf(“Illegal HTTP2_READ_IDLE_TIMEOUT_SECONDS(%q): %v.”+ “ Default value %d is used”, s, err, ret) return ret } ret = i } return ret }
func pingTimeoutSeconds() int { ret := 15 if s := os.Getenv(“HTTP2_PING_TIMEOUT_SECONDS”); len(s) > 0 { i, err := strconv.Atoi(s) if err != nil { klog.Warningf(“Illegal HTTP2_PING_TIMEOUT_SECONDS(%q): %v.”+ “ Default value %d is used”, s, err, ret) return ret } ret = i } return ret }
每个集群都有一个 NLB 对 API Server 的连接进行负载均衡。这些 NLB 配置了客户端 IP 保留(client-ip preservation)和跨 AZ 负载均衡(cross zone load balancing)。启用客户端 IP 保留,是为了让通过 NLB 的并发连接数能够超过 NLB 约 65000 个的临时端口范围限制。启用跨 AZ 负载均衡,则是为了提升可靠性并增强对部分控制平面故障的抗风险能力。
API Server 与 kube-controller-manager
我们使用 kOps(一款 Kubernetes 集群管理工具)来引导集群的创建。每个集群有 5 个控制平面节点。kube-controller-manager、kube-scheduler 等所有控制平面组件都运行在控制平面节点上,并配置为通过 NLB 与 API Server 通信。
我们为 API Server 前置的 NLB 启用了一项名为客户端 IP 保留的功能。该功能实质上是将 TCP 数据包的源 IP 和端口替换为发送方的真实 IP 和端口,而不是 NLB 自身的。这使目标端能够接受更多连接,同时保留了 IP 信息,便于追踪和审计等用途。除客户端 IP 保留外,我们还启用了跨 AZ 负载均衡,允许 NLB 路由至任意后端目标。
总结: 启用了客户端 IP 保留与跨 AZ 负载均衡,节点设置了 tcp_tw_reuse=1,同一客户端(相同的源 IP 和源端口)通过不同的 NLB IP 路由后,落到了同一目标(相同的目标 IP 和目标端口)。这导致负载均衡目标节点在已建立的 TCP 连接上收到了意外的 SYN,并发送了 Challenge ACK;客户端回复 RST,将服务器端的连接切断。这使现有的长连接 HTTP/2 流(例如 informer Watch 连接)在超时 45 秒后收到 client connection lost 错误。
NLB 官方文档
但这不就是 NLB 的 bug 吗?其实,这与其说是 bug,不如说是 NLB 启用客户端 IP 保留与跨 AZ 负载均衡后的一个既定特性。正如 AWS 在其 NLB “要求和注意事项”中所述:
当启用客户端 IP 保留时,不支持 NAT 回路(也称为 hairpinning)。启用后,当客户端或其前端的 NAT 设备在同时连接多个负载均衡器节点时使用相同的源 IP 地址和源端口,您可能会遇到与目标节点上的 socket 复用相关的 TCP/IP 连接限制。如果负载均衡器将这些连接路由到同一目标,目标节点会认为它们来自相同的源 socket,从而导致连接错误。如果发生这种情况,客户端可以重试(如果连接失败)或重新连接(如果连接中断)。您可以 通过增加源端临时端口数或增加负载均衡器目标数来减少此类连接错误。也可以通过禁用客户端 IP 保留或禁用跨 AZ 负载均衡来完全避免此类连接错误。
首先,在应用端进行排队,因为很多商品都是有sku的,当sku库存变化时item的库存也要做相应变化,所以需要根据itemId来进行排队,相同itemId的减库存操作会进入串行化排队处理逻辑,不过应用端的排队只能做到单机内存排队,当应用服务器数量过多时,落到db的并发请求仍然很多,所以最好的办法是在db端也加上排队策略,今年库存中心db部署了两个的排队patch,一个叫“并发控制”,是做在InnoDB层的,另一个叫“queue on pk”,是做在mysql的server层的,两个patch各有优缺点,前者不需要应用修改代码,db自动判断,后者需要应用程序写特殊的sql hint,前者控制的全局的sql,后者是根据hint来控制指定sql,两个patch的本质和应用端的排队逻辑是一致的,具体实现不同。双十一库存中心使用的是“并发控制”的patch。
2013年的单减库存TPS最高记录是1381次每秒。
对于秒杀热点场景,官方版本500tps每秒,问题在于同时涌入的请求太多,每次取锁都要检查其它等锁的线程(防止死锁),这个线程队列太长的话导致这个检查时间太长; 继续在前面增加能够进入到后面的并发数的控制,通过增加线程池、控制并发能到1400(no deadlock list check);
热点的自动识别:前面已经讲过了,库存的扣减SQL都会有commit on success标记。mysql内部分为普通通道和热点扣减通道。普通通道里是正常的事务。热点通道里收集带有commit on success标记的事务。在一定的时间区间段内(0.1ms),将收集到的热点按照主键或者唯一键进行hash; hash到同一个桶中为相同的sku; 分批组提交这0.1ms收集到的热点商品。
The thread_pool_stall_limit affects executing statements. The value is the amount of time a statement has to finish after starting to execute before it becomes defined as stalled, at which point the thread pool permits the thread group to begin executing another statement. The value is measured in 10 millisecond units, so the default of 6 means 60ms. Short wait values permit threads to start more quickly. Short values are also better for avoiding deadlock situations. Long wait values are useful for workloads that include long-running statements, to avoid starting too many new statements while the current ones execute.
类似案例
一个其它客户同样的问题的解决过程,最终发现是因为thread pool group中的active thread count 计数有泄漏,导致达到thread_pool_oversubscribe 的上限(实际没有任何线程运行)
MySQL Thread Pool之所以分成多个小的Thread Group Pool而不是一个大的Pool,是为了分解锁(每个group中都有队列,队列需要加锁。类似ConcurrentHashMap提高并发的原理),提高并发效率。另外如果对每个Pool的 Worker做CPU 亲和性绑定也会对cache更友好、效果更高
Ping use the JDBC method Connection.isValid(int timeoutInSecs). Digging into the MySQL Connector/J source, the actual implementation uses com.mysql.jdbc.ConnectionImpl.pingInternal() to send a simple ping packet to the DB and returns true as long as a valid response is returned.
MySQL ping protocol是发送了一个 0e 的byte标识给Server,整个包加上2byte的Packet Length(内容为:1),2byte的Packet Number(内容为:0),总长度为5 byte。Druid、DRDS默认都会 testOnBorrow,所以每个连接使用前都会先做ping。
1 2 3 4 5 6 7 8 9 10 11 12
public class MySQLPingPacket implements CommandPacket { private final WriteBuffer buffer = new WriteBuffer(); public MySQLPingPacket() { buffer.writeByte((byte) 0x0e); } public int send(final OutputStream os) throws IOException { os.write(buffer.getLengthWithPacketSeq((byte) 0)); // Packet Number os.write(buffer.getBuffer(),0,buffer.getLength()); // Packet Length 固定为1 os.flush(); return 0; } }
"ManagerExecutor-1-thread-1" #47 daemon prio=5 os_prio=0 tid=0x00007fe924004000 nid=0x15c runnable [0x00007fe9034f4000] java.lang.Thread.State: RUNNABLE at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.socketRead(SocketInputStream.java:116) at java.net.SocketInputStream.read(SocketInputStream.java:171) at java.net.SocketInputStream.read(SocketInputStream.java:141) at com.mysql.jdbc.util.ReadAheadInputStream.fill(ReadAheadInputStream.java:101) at com.mysql.jdbc.util.ReadAheadInputStream.readFromUnderlyingStreamIfNecessary(ReadAheadInputStream.java:144) at com.mysql.jdbc.util.ReadAheadInputStream.read(ReadAheadInputStream.java:174) - locked <0x0000000722538b60> (a com.mysql.jdbc.util.ReadAheadInputStream) at com.mysql.jdbc.MysqlIO.readFully(MysqlIO.java:3005) at com.mysql.jdbc.MysqlIO.reuseAndReadPacket(MysqlIO.java:3466) at com.mysql.jdbc.MysqlIO.reuseAndReadPacket(MysqlIO.java:3456) at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3897) at com.mysql.jdbc.MysqlIO.sendCommand(MysqlIO.java:2524) at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2677) at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2545) - locked <0x00000007432e19c8> (a com.mysql.jdbc.JDBC4Connection) at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2503) at com.mysql.jdbc.StatementImpl.executeQuery(StatementImpl.java:1369) - locked <0x00000007432e19c8> (a com.mysql.jdbc.JDBC4Connection) at com.alibaba.druid.pool.ValidConnectionCheckerAdapter.isValidConnection(ValidConnectionCheckerAdapter.java:44) at com.alibaba.druid.pool.DruidAbstractDataSource.testConnectionInternal(DruidAbstractDataSource.java:1298) at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:1057) at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:997) at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:987) at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:103) at com.taobao.tddl.atom.AbstractTAtomDataSource.getConnection(AbstractTAtomDataSource.java:32) at com.alibaba.cobar.ClusterSyncManager$1.run(ClusterSyncManager.java:60) at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1152) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:627) at java.lang.Thread.run(Thread.java:882)
X 应用通过线程池来接收一个逻辑SQL并处理,如果需要查询1024分片的sort merge join,相当于派生了1024个子任务,每个子任务占用一个线程,父任务等待子任务执行后返回数据。如果这样的逻辑SQL同时来一批并发,就会出现父任务都在等子任务,子任务又因为父任务占用了线程,导致子任务也在等着从线程池中取线程,这样父子任务就进入了死锁
publicstaticfinal ServerThreadPool create(String name, int poolSize, int deadLockCheckPeriod, int bucketSize) { returnnewServerThreadPool(name, poolSize, deadLockCheckPeriod, bucketSize); //bucketSize可以设置 }
publicServerThreadPool(String poolName, int poolSize, int deadLockCheckPeriod, int bucketSize) { this.poolName = poolName; this.deadLockCheckPeriod = deadLockCheckPeriod;
This is an entry in the TLB that points to a HugePage (a large/big page larger than regular 4K and predefined in size). HugePages are implemented via hugetlb entries, i.e. we can say that a HugePage is handled by a “hugetlb page entry”. The ‘hugetlb” term is also (and mostly) used synonymously with a HugePage.
Linux 中的 HugePages 都被锁定在内存中,所以哪怕是在系统内存不足时,它们也不会被 Swap 到磁盘上,这也就能从根源上杜绝了重要内存被频繁换入和换出的可能。
Transparent Hugepages are similar to standard HugePages. However, while standard HugePages allocate memory at startup, Transparent Hugepages memory uses the khugepaged thread in the kernel to allocate memory dynamically during runtime, using swappable HugePages.
HugePage要求OS启动的时候提前分配出来,管理难度比较大,所以Enterprise Linux 6增加了一层抽象层来动态创建管理HugePage,这就是THP,而这个THP对应用透明,由khugepaged thread在后台动态将小页组成大页给应用使用,这里会遇上碎片问题导致需要compact才能得到大页,应用感知到的就是SYS CPU飙高,应用卡顿了。
Huge pages require contiguous areas of memory, so allocating them at boot is the most reliable method since memory has not yet become fragmented. To do so, add the following parameters to the kernel boot command line:
Huge pages kernel options
hugepages
Defines the number of persistent huge pages configured in the kernel at boot time. The default value is 0. It is only possible to allocate (or deallocate) huge pages if there are sufficient physically contiguous free pages in the system. Pages reserved by this parameter cannot be used for other purposes.
Default size huge pages can be dynamically allocated or deallocated by changing the value of the /proc/sys/vm/nr_hugepages file.
In a NUMA system, huge pages assigned with this parameter are divided equally between nodes. You can assign huge pages to specific nodes at runtime by changing the value of the node’s /sys/devices/system/node/node_id/hugepages/hugepages-1048576kB/nr_hugepages file.
For more information, read the relevant kernel documentation, which is installed in /usr/share/doc/kernel-doc-kernel_version/Documentation/vm/hugetlbpage.txt by default. This documentation is available only if the kernel-doc package is installed.
hugepagesz
Defines the size of persistent huge pages configured in the kernel at boot time. Valid values are 2 MB and 1 GB. The default value is 2 MB.
default_hugepagesz
Defines the default size of persistent huge pages configured in the kernel at boot time. Valid values are 2 MB and 1 GB. The default value is 2 MB.
If you are using the option -XX:+UseSHM or -XX:+UseHugeTLBFS, then specify the number of large pages. In the following example, 3 GB of a 4 GB system are reserved for large pages (assuming a large page size of 2048kB, then 3 GB = 3 * 1024 MB = 3072 MB = 3072 * 1024 kB = 3145728 kB and 3145728 kB / 2048 kB = 1536):
“Zero-copy“ describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.
零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 和内存带宽。
如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。 数据传输过程就再也没有 CPU 的参与了,也因此 CPU 的高速缓存再不会被污染了,也不再需要 CPU 来计算数据校验和了,CPU 可以去执行其他的业务计算任务,同时和 DMA 的 I/O 任务并行,此举能极大地提升系统性能。
BSS 段: 对于未初始化的全局变量和静态变量,因为编译器知道它们的初始值都是 0,因此便不需要再在程序的二进制映像中存放这么多 0 了,只需要记录他们的大小即可,这便是BSS段 。BSS 段这个缩写名字是 Block Started by Symbol,但很多人可能更喜欢把它记作 Better Save Space 的缩写。
DMA is the low 16 MBytes of memory. At this point it exists for historical reasons; once upon what is now a long time ago, there was hardware that could only do DMA into this area of physical memory.
DMA32 exists only in 64-bit Linux; it is the low 4 GBytes of memory, more or less. It exists because the transition to large memory 64-bit machines has created a class of hardware that can only do DMA to the low 4 GBytes of memory.(This is where people mutter about everything old being new again.)
Normal is different on 32-bit and 64-bit machines. On 64-bit machines, it is all RAM from 4GB or so on upwards. On 32-bit machines it is all RAM from 16 MB to 896 MB for complex and somewhat historical reasons. Note that this implies that machines with a 64-bit kernel can have very small amounts of Normal memory unless they have significantly more than 4GB of RAM. For example, a 2 GB machine running a 64-bit kernel will have no Normal memory at all while a 4 GB machine will have only a tiny amount of it.
HighMem exists only on 32-bit Linux; it is all RAM above 896 MB, including RAM above 4 GB on sufficiently large machines.
答: 能,这个时候在client和server两边看到的连接状态都是 ESTABLISHED,只是Server上的全连接队列占用加1。连接握手并切换到ESTABLISHED状态都是由OS来负责的,应用不参与,ESTABLISHED后应用才能accept,进而收发数据。也就是能放入到全连接队列里面的连接肯定都是 ESTABLISHED 状态的了
接着上面的问题,如果新连接继续连接进而全连接队列满了呢?
答:那就连不上了,server端的OS因为全连接队列满了直接扔掉第一个syn握手包,这个时候连接在client端是SYN_SENT,Server端没有这个连接,这是因为syn到server端就直接被OS drop 了。