调优-cpu毛刺问题

摘要

1。线上服务追踪使用公共线程池的调用栈;
2。动态调整线程池大小;
3。拓展:pstack、strace非java进程;

背景

线上服务偶尔会有一两个实例突然cpu飙到100%,尤其以刚启动的时候发生的概率高。
虽然分钟级的平均使用率只有35%左右,但是秒级则会有几秒进入进程卡顿状态,影响服务的可用性。

问题定位

1。定位线程池:

cpu问题,首先想到的是线程池打满的可能。所以首先看监控里各个线程池的使用率。
然后发现打满的线程池名字是: fork-join-common-pool

这里就很尴尬了,因为如果是业务命名好的线程池,就可以立即知道涉及到的业务和相关代码的位置了。

这里监控线程池使用率用的是java.util.concurrent.ThreadPoolExecutor.getActiveCount
或java.util.concurrent.ForkJoinPool.getActiveThreadCount
对比java.util.concurrent.ThreadPoolExecutor.getMaximumPoolSize
或java.util.concurrent.ForkJoinPool.getParallelism
启动所有线程池的时候注册一下reporter即可。

2。追踪调用栈

这种不命名线程池的不规范的使用,给定位问题带来了麻烦。
需要登陆到容器里,然后用arthas连上jvm:

script
1
2
stack java.util.concurrent.ForkJoinPool externalSubmit -n 3
# 需要设置options unsafe true

看一下提交任务的代码的调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ts=2022-08-24 18:16:20;thread_name=csc-infra-executor-348;id=f2e45;is_daemon=false;priority=5;TCCL=jdk.internal.loader.ClassLoaders$AppClassLoader@531d72ca
@java.util.concurrent.ForkJoinPool.signalWork()
at java.util.concurrent.ForkJoinPool.externalPush(ForkJoinPool.java:1903)
at java.util.concurrent.ForkJoinPool.externalSubmit(ForkJoinPool.java:1921)
at java.util.concurrent.ForkJoinPool.execute(ForkJoinPool.java:2453)
at com.github.benmanes.caffeine.cache.BoundedLocalCache.scheduleDrainBuffers(BoundedLocalCache.java:1427)
at com.github.benmanes.caffeine.cache.BoundedLocalCache.scheduleAfterWrite(BoundedLocalCache.java:1394)
at com.github.benmanes.caffeine.cache.BoundedLocalCache.afterWrite(BoundedLocalCache.java:1364)
at com.github.benmanes.caffeine.cache.BoundedLocalCache.doComputeIfAbsent(BoundedLocalCache.java:2470)
at com.github.benmanes.caffeine.cache.BoundedLocalCache.computeIfAbsent(BoundedLocalCache.java:2386)
at com.github.benmanes.caffeine.cache.LocalCache.computeIfAbsent(LocalCache.java:108)
at com.github.benmanes.caffeine.cache.LocalLoadingCache.get(LocalLoadingCache.java:54)
at com.github.benmanes.caffeine.guava.CaffeinatedGuavaLoadingCache.get(CaffeinatedGuavaLoadingCache.java:59)
at

结果挺意外的,原来是使用的caffeine的缓存默认用的公共线程池。
翻了一下公司封装的接口里没法往里面传自己的线程池,不传默认就用公共forkjoin线程池,worker数量=core * 2;
因此吞吐量太低了。一旦遇到有大量缓存需要重载的时候,就会卡住,因此会cpu毛刺。
平时因为设置得过期时间和刷新时间好,不会触发;启动的时候,缓存还是空的,因此线程池会打满,请求堆积、cpu毛刺。

3。解决方案

首先是推动公司基础部门把这个漏暴露的接口补上;
其次是使用自己创建的线程池时,要确定线程池的大小。
这个问题一方面有网上的公式:

cpu密集: 线程池大小=core * 2;
IO密集: 线程池大小= IO时间/cpu时间 * 2;

其实一般业务系统,又不是机器学习之类的算法程序,基本上都是IO密集;
可以算一下下游返回时间大概多少(超时时间),除以(总时间-IO时间)即可。

除了根据上面的公式直接拍,如果后续下游有变化,动态调整也很重要,所以最好是用可以动态调整线程池大小的封装。
可以借用com.google.common.util.concurrent.ForwardingExecutorService,稍微封装一下。
主要注意的就是调大corePoolSize之前,要先调大maximumPoolSize;
反之则反过来。(也不是每种线程池都能调,jdk默认是让调的)

4。拓展

jvm进程我们一般先:
1.top一下看进程id;
2.top -Hp <pid>看进程内线程id(十进制);
3.printf "%x\n" <pid>看16进制nid;
4.jstack <pid> | grep <nid> -A 30看具体栈;

如果是非jvm程序,则不能用jstack,可以用pstack:

script
1
2
3
4
5
6
pstack <线程id>
```
不过信息就比jstack少很多了。
还可以:
```shell script
strace -o strace.log -tt -p <线程id>

追踪运行时的汇编。

w-tinylfu缓存算法

WHAT: w-tinylfu是什么?

w-tinylfu = window tiny LFU
实际是一种缓存置换算法,直译过来大概是带一个窗口的tiny版本的LFU实现;
主要用三段LRU,一个布隆过滤器、一个cm-sketch计数器,兼顾了LFU和LRU的优点。
大致思想上类似于别的分段lru算法(例如mysql中buffer pool的lru链表,5/8存young page,3/8存old page)。

背景

学术界:
来自论文:https://arxiv.org/pdf/1512.00727.pdf
工业界:
实现:java中的caffeine缓存就用的是w-tinylfu算法。

当然这里caffeine的性能好除了w-tinylfu算法的贡献,还有其他很多优化比如结合了disruptor(RingBuffer)、时间轮;

WHEN: 什么时候使用w-tinylfu算法

缓存置换算法也不是银弹,也需要匹配它适合的某些分布workload。
即使是匹配的分布的workload,实际参数也要和workload匹配才能达到最好性能。

w-tinylfu算法适合的工作负载的大概特征:

  1. 静态的偏态分布;(分布规律不怎么变化)
  2. 有一定动态变化(不能变化得太快太剧烈)的分布、包括有一定突发流量的偏态分布,例如偶有突发流量的zipf分布。

比较合适的:

benchmark中测试的workload比较合适的包括:
youtube、维基百科、数据库访问、windows文件系统、搜索引擎;

不太合适的:

OLTP中的磁盘访问: 轨迹实际是顺序访问的升序队列中散布一些随机访问,这对于w-tinylfu来说不太适配。

具体的调参涉及到算法内部细节,因此最后在调优章节再展开。

HOW: w-tinylfu具体是怎么做的

目标、要解决的问题

1。提高缓存命中率;
现有方案的缺点:
LFU: 数据访问模式有变化时(例如有突发流量),LFU较迟钝,始终缓存早先访问多的数据;
LRU: 不能记忆历史的访问规律,高频访问key并不一定能缓存到。
2。降低内存消耗;
现有方案的缺点:
LFU: 需要给每个key维护频率信息(PLFU记录所有key的频率;WLFU则记录当前缓存中key的频率),需要巨大的内存消耗。

总之,w-tinylfu需要以较低的内存消耗达到高命中率。

架构、解决方案

如上图所示:
1。LRU: 最左边是一个普通的LRU窗口,默认占1%的空间,负责处理突发流量的情况;
2。filter: 第2个组件是一个布隆过滤器,作为看门人,负责阻挡长尾的低频访问、避免纳入计数器;
3。Segmented LRU: 第3个组件是分段LRU,分为两段:
(1)protected: 受保护区,占80%,存放访问次数>1的数据;
(2)probation: 缓刑区域, 占20%,存放访问次数=1的数据;(淘汰备选)
按我理解,其实还应该有一个组件负责统计频次,但论文中没有相应的图。
这里按我理解直接补上:
4。CM-Sketch: 近似统计频次。这里类似于WLFU,只记录当前缓存中有出现的key的频率,使用count min sketch算法。
近似统计频次算法,因此有一定错误率,但也极大节省了内存。

节省内存:CM-Sketch算法近似统计

count min sketch算法,类似于计数版本的布隆过滤器,把布隆过滤器原来标记是否存在的0\1改成能存更多值的计数器。
由于考虑到实际workload中常见的是长尾分布,因此计数器实际位数不多,默认只有4位,也就是默认大部分数据访问频次在15次以内。
CM-SKetch使用多个哈希函数(或者1个哈希函数和多个种子)和多个计数器来记录访问的次数。
实际读的时候取所有计数器里最小的一个,因此其他计数器的值其实就没什么用了。
而且由于哈希碰撞,有一定的概率频次的估计会偏大,(但是不会偏小)
因此实际每个key的统计频次增加的时候,可以只增大当前最小的计数器。

调参

假如处理的数据规模是n,希望估计值落在[实际值, 实际值+ a*n]范围的概率是1-b;
则:
1.计数器数量为: e/a 的上界;
2.哈希函数的数量为: ln(1/b) 的上界;

例如当n=10^6时,如果我们希望估计值落在实际值, 实际值+ 2000范围的概率是99%,
则:

  1. 计数器数量为: e/0.002 的上界 = 1360;
  2. 哈希函数的数量为: ln(1/0.01) 的上界 = 5;
    当计数器占4bit,则整个计数组件占空间 = 136054 bit= 约3.5KB。

动态访问模式(访问key的分布变化):Freshness Mechanism(保鲜机制)

统计新增key到CM-Sketch计数器的次数w,一旦w达到阈值W的大小时,进行reset操作。
reset操作实际有两方面:
(1)将所有计数器的值减半(可以通过简单的>>&0x77操作来实现);
// 副作用:可能除不尽,计数误差+1。
(2)将看门人的bloom filter清空。
// 副作用: 计数误差+1;

节省内存、长尾分布优化:Doorkeeper(看门人)机制

解决的问题:长尾分布。现实时间的访问轨迹大多符合长尾效应的偏态分布,也就是大部分访问key只访问很少次数,比如1次。
如果这些流量也纳入近似统计用的CM-Sketch,就会极大扩大本来就有概率发生的哈希碰撞,从而影响性能。
如果要维持出错概率不上升,只能增大计数器的槽位(哈希表的格数),增加内存消耗。

因此这里的DoorKeeper机制,就是在近似统计组件前面增加一个防护存储: 一个布隆过滤器。
在纳入CM-Sketch之前,首先要先检查一下在不在filter里面:
(1)如果有: 说明之前可能有访问过,通过,纳入计数器;
(1)如果没有: 说明之前没有访问过,拒绝写入计数器;但写入filter记录下来。// 下次如果再访问则能通过。

通过这种看门人机制,可以阻拦大部分只访问1次的key,避免将大量资源消耗在这些key上面。

读写流程

lru或者segment lru部分读取,然后增加计数器的值即可。
可能导致probation中的key升级到protected

如上图所示,新item插入的时候,首先进入lru;
如何如果lru满了,淘汰者和分段lru的淘汰者pk,(根据近似计数器中的频次)。

而在分段lru中,probation缓刑区中访问则升到protected区。(反之protected的淘汰者降级到probation)

调优参数

回顾各个组件:
1。LRU: 默认占1%的空间,负责处理突发流量的情况;
因此如果实际突发流量占不止1%的内存空间,可能需要调大这部分;(反之亦然)
2。filter: 布隆过滤器; 只拦了访问次数=1的,因此如果长尾是2次,则可能要调整这部分。
3。Segmented LRU:
(1)protected: 受保护区,占80%,存放访问次数>1的数据;
(2)probation: 缓刑区域, 占20%,存放访问次数=1的数据;(淘汰备选)

4。CM-Sketch: 默认是4位计数器,最大15次。隐含假设是大部分访问<15次,因此如果这个假设不成立,也要调整这部分。

参考资料

论文: https://arxiv.org/pdf/1512.00727.pdf
https://xuzhijvn.github.io/zh-cn/posts/cs/other/caffeine/
https://jishuin.proginn.com/p/763bfbd34443
cm-sketch算法: https://zhuanlan.zhihu.com/p/369981005

G1调优-复杂业务治理小记

摘要

三板斧:

  1. Region size;
  2. Profile出不合理的内存分配代码,优化或者迁移;
  3. 确实有比较高内存消耗的场景,提早收集;

背景

一般来说8~64GB的堆用G1垃圾收集器还是比较省心,只需要配置regionSize和停顿时间基本就不用管了。
相关参数: -XX:G1HeapRegionSize=16M, -XX:MaxGCPauseMillis=200

不一般的情况也偶有发生,
工作中我们的一个老服务就遇到了停顿时间太长的问题。
这个老服务首先jdk版本较低只有8,
其次服务里有非常多不同类型的业务接口,承担了极为复杂的业务功能。
复杂度正比于 ~ 公司所有其他对外业务的功能*公司内大量对内业务的功能。

问题

线上服务gc停顿时间达到3~5s,偶尔甚至达到10s.

解决方案

1。 收集信息

1。 通过arthasdashboardjstat -gcutils观察内存消耗特别快,gc日志里能看到时不时就to-space耗尽了。

2。 但是经过gc能够回收,说明没有太长时间的内存泄露,用jmap就很难找到对应的内容或者相关代码了。
// 同时jmap会卡住线上服务进入safepoint,停顿时间分钟级,然后上传、加载估计半小时起步,风险和成本太高,收益较难达到。

3。 预发环境没有问题,只有线上有,说明是用户请求触发的。

4。 gc日志里source: concurrent humongous allocation较多。

2。 profile

如果jdk16的话,可以长期开着Profile(sample事件),但是这个老服务是jdk8,因此需要重新监控一下内存分配的事件:

script
1
./async-profiler-2.7-linux-x64/profiler.sh -d 300 -f 5min.jfr -e alloc --alloc 10m <pid>

首先记录下线上服务进程的jfr黑匣子,然后再输出一下ObjectAllocationOutsideTLAB事件并统计:

script
1
2
3
4
# 分配大对象次数最多: :
jfr print --events jdk.ObjectAllocationOutsideTLAB 5min.jfr | grep allocationSize | awk -F'=' '{print $2}' | sort | uniq -c | sort -n
# 每次分配内存对象最大:
jfr print --events jdk.ObjectAllocationOutsideTLAB 5min.jfr | grep allocationSize | awk -F'=' '{print $2}' | sort | uniq | sort -n

找到对应的allocationSize以后,再grep出对应的堆栈:

script
1
jfr print --events jdk.ObjectAllocationOutsideTLAB --stack-depth 16 5min.jfr | grep 19538928 -A 16 -B 3

至此就抓到疯狂分配内存的元凶代码了。

3。改代码

这里因为是18MB * 上百次/每秒的内存申请,由于我们的regionSize是16MB,18MB是大对象(即使我们设置成最大32MB也还是会判定成大对象)
,而且内存碎片特别多(18MB要两个region才能放得下,也就是浪费16*2-18=14MB)。所以调gc参数的方式处理的话比较困难。

另一个方向是改代码,这里同事是在logger.debug的时候写了一个toJSON方法,虽然不会打印,但toJSON还是执行了。
将超大的对象数组toJSON,因此内存消耗巨大。
所以我们简单把日志改成sl4j的fluentApi,用suplier做toJSON就解决了问题。
重新上线,gc停顿时间消失。

1
logger.atDebug().log(() -> toJSON(args));

回顾:WHY

内存分配

java创建对象的内存分配从好到坏有4种:
1.栈上分配: 如果是线程封闭的、足够小的对象,可以优化到栈上;
2.TLAB分配;
3.outside TLAB+eden分配;
4.H region分配;

cms的gc触发条件

cms的gc分为:

  • 前台收集
  • 后台收集

前台收集

触发条件:对象分配,空间不够;
算法: 标记清除(有碎片)

后台收集

定时任务扫描:(间隔=CMSWaitDuration=2s)

  1. 有显式调用(System.gc());
  2. 预测要满了(根据历史统计数据,且未配置UseCMSInitiatingOccupancyOnly);
    (第一次的话50%老年代占用就开始gc了)
  3. 老年代占用>阈值;
  4. Young GC可能失败或已失败;(没空间晋升了)
  5. metaspace扩容前,会进行一次cms gc;

压缩gc(full gc)

触发条件: 前台收集之前,cms可能选择进行一次压缩gc(full gc);(yong+old+metaspace)

触发条件:
(1)gc次数(前台收集+压缩full gc) > CMSFullGCsBeforeCompaction;(默认0,每次都full gc)
(2)System.gc(),则触发;
(3)young gc可能失败或已经失败(晋升失败),则认为碎片太多,需要full gc压缩一下;
(4)配置了CMSCompactWhenClearAllSoftRefs, 则需要在内存不够时清理软引用,所以需要full gc;

减少这4种情况的触发,则可以减少full gc,提高性能。

java方法是否可中断梳理

常见方法的中断相关汇总

调用方法 是否可以中断 是否释放资源(锁) 是否释放cpu
synchronized 不可中断 不释放 释放
lock 不可中断 不释放 释放
tryLock 可以中断 不释放 释放
lockInterruptibly 可以中断 不释放 释放
InterruptibleChannel 可以中断 不释放 释放
Thread.sleep 可以中断 不释放 释放
thread.join() 可以中断 释放thread对象的锁,其他不释放 释放
object.wait() 可以中断 释放 释放
condition.await() 可以中断 释放 释放

其中thread.join底层其实是调用了thread对象的wait方法,之后一般被jvm的notify唤醒。
源码参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public final synchronized void join(long millis) // 方法上有synchronized
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;

if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}

if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay); // 实际调用的是wait,由notify唤醒
now = System.currentTimeMillis() - base;
}
}
}

总结
lock,synchronized容易死锁,因为不可中断。
尽量用表格下方的方法,性能会高些。

内核态

What: 什么是内核态

访问不属于自己的内存(用户空间、虚拟内存地址)时,
需要系统调用,进入cpu特权模式,因此需要切换到内核态;

切换到内核态时是否一定要释放cpu?
只是把寄存器保存到进程内核栈而已,进程cpu并不需要释放;

进程切换、线程切换是否需要切换内核态?
进程切换、线程切换:需要用到内核里的数据结构,因此需要进入内核态;

操作系统线程库

1)POSIX Pthreads:可以作为用户或内核库提供,作为 POSIX 标准的扩展
2)Win32 线程:用于 Window 操作系统的内核级线程库

java的线程库

Java 线程 API 通常采用宿主系统的线程库来实现。
也就是说在 Win 系统上,Java 线程 API 通常采用 Win API 来实现;
在 UNIX 类系统上,采用 Pthread 来实现。

具体到hotpot实现,则JVM线程跟内核轻量级进程一一对应。

mp4格式

WHAT: MP4

一种封装格式(容器格式),封装视频和音频、还有海报、字幕和元数据等;

常见的封装格式: MKV,AVI,MP4

MP4文件格式又被称为 MPEG-4 Part 14,出自 MPEG-4 标准第 14 部分。

MP4格式

协议分为两阶段:
(1)MPEG-4 Part 12: 定义ISO基础媒体文件格式,存储基于时间的媒体内容;
(2)MPEG-4 Part 14: 在MPEG-4 Part 12基础上拓展,实际定义了MP4;
实际格式:

1
2
3
4
[
Box
,Box:[Box,Box:[Box]]
]

多个box组成,box可嵌套;

Box

不同的Box类型:
(1)ftyp: File Type Box; 描述MP4规范与版本;最好用什么版本来解析、兼容哪些版本的解析;
(2)moov: Movie Box; 媒体的metadata信息;1个;
next_track_ID: 下一个轨道的id;(如果要新增)
volume: 播放音量;
单轨属性:
layer: 视频轨道的叠加顺序,数字越小越靠近观看者;
alternate_group:track的分组ID,同个分组里的track,只能有一个track处于播放状态;
width、height:视频的宽高
(3)mdat: Media Data Box;实际媒体数据;多个;
(4)…其他box类型;

moov

ffmpeg默认情况下生成moov是在mdat写完成之后再写入,所以moov是在mdat的后面,使用faststart参数可以将moov移到mdat前面。
(moov在前面的话,首帧渲染速度会更快一点)

script
1
ffmpeg -i out.flv -c copy -movflags faststart out2.mp4

媒体数据结构划分

MP4媒体数据 -> chunk -> sample -> 帧

H264编码中的帧:

I帧: 关键帧;存的是完整的JPEG数据;
P帧: 前向预测编码帧;存的是与之前帧的差别;(参考别的帧)
B帧: 双向预测内插编码帧;双向差别,存的是和前帧、后帧的差别;(所以压缩率比P帧高,cpu占用高)

对于B帧,视频帧的解码顺序和渲染顺序不一致。(先解前后,才解中间;而渲染是顺序来的)

// H.265是一种视频压缩标准,仅需H.264的一半带宽即可播放相同质量的视频。

H264流格式

1。Annex-B
0x00000001分割;
2。RTP
长度+数据的格式;

fmp4

mp4: 点播
fmp4: 直播

时长: mp4固定,fmp4不固定(边生成边播);
元数据: mp4->moov box;
fmp4->moof box,和mdat通常结对出现;(流式)

mp4相关工具

在线解析mp4的工具: https://gpac.github.io/mp4box.js/test/filereader.html
mp4dump、mp4edit、mp4encrypt等工具: http://www.bento4.com/
https://github.com/gpac/gpac/wiki/MP4Box
gpac:开源的多媒体工具包,包括用于MP4打包的mp4box等。https://github.com/gpac/gpac
mp4v2:提供了API来创建和修改mp4文件。https://code.google.com/archive/p/mp4v2/

参考

https://segmentfault.com/a/1190000039270533

ss命令

ss命令

Socket Statistics
权威参考: https://man7.org/linux/man-pages/man8/ss.8.html

查看tcp连接的统计信息:

script
1
ss -t | head

可以看到tcp连接的状态、收发情况、双方地址:

script
1
2
State      Recv-Q Send-Q Local Address:Port                 Peer Address:Port
CLOSE-WAIT 1 0 10.28.234.170:64540 10.36.41.26:14326

Sockets摘要信息:

script
1
ss -s

查看已建立的http

script
1
ss -o state established '( dport = :http or sport = :http )'

查看监听的端口

script
1
ss -l

查看进程占用的端口

script
1
ss -tnlp  | grep pid=97

状态过滤

script
1
2
ss -4 state time-wait # 仅显示ipv4
ss -6 # 仅显示ipv6

状态的枚举可以通过 ss -h查看帮助来获取。

查看内存占用

ss -m即可,具体输出的格式的定义如下:

script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
skmem:(r<rmem_alloc>,rb<rcv_buf>,t<wmem_alloc>,tb<snd_buf>,
f<fwd_alloc>,w<wmem_queued>,o<opt_mem>,
bl<back_log>,d<sock_drop>)

<rmem_alloc>
the memory allocated for receiving packet

<rcv_buf>
the total memory can be allocated for receiving
packet

<wmem_alloc>
the memory used for sending packet (which has been
sent to layer 3)

<snd_buf>
the total memory can be allocated for sending
packet

<fwd_alloc>
the memory allocated by the socket as cache, but
not used for receiving/sending packet yet. If need
memory to send/receive packet, the memory in this
cache will be used before allocate additional
memory.

<wmem_queued>
The memory allocated for sending packet (which has
not been sent to layer 3)

<ropt_mem>
The memory used for storing socket option, e.g.,
the key for TCP MD5 signature

<back_log>
The memory used for the sk backlog queue. On a
process context, if the process is receiving
packet, and a new packet is received, it will be
put into the sk backlog queue, so it can be
received by the process immediately

<sock_drop>
the number of packets dropped before they are de-
multiplexed into the socket

UDP批处理优化-GSO/GRO

GSO和GRO

GSO: Generic Segmentation Offload
发送端;推迟发送端网络包分片,提高大包发送性能;
GRO: Generic Receive Offload
接受端;提早合并到达网卡的小包;提高接受性能;

GSO原理

before:
刚开始就分片,每个小分片参与后续的IP层、链路层、网卡驱动的系统调用;
after:
在网卡发送前再分片,减少系统调用。
// 1。 网卡支持:由网卡分片;
// 2。 网卡不支持:发送到网卡前一刻,内核做分片;

GRO

GRO类似于GSO,都是在离网卡最近的地方做分片、合并之类的事情。(批处理)

效果

cpu消耗:降低
网络吞吐优化:25%
系统调用次数降低:97%
系统耗时降低:90%

为什么先hash后加密的方法容易遭遇padding攻击

tls1.2实际加密方式

实际上加密算法是需要padding的,最早的padding方法是:
AES(text+MAC(text)+padding)
后来因为这种方式容易遭遇padding攻击,因此tls1.3采用了更安全的padding方法:
E=AES(text+padding)
然后: E+MAC(E)

padding攻击,通过反复修改部分内容、并触发解密过程,从而探测猜测加密算法的密钥;

Why

padding oracle attack:
https://zh.wikipedia.org/wiki/%E5%AF%86%E6%96%87%E5%A1%AB%E5%A1%9E%E6%94%BB%E5%87%BB
从攻击方式可以看出,攻击者主要是以
1。修改最后1个字节;
2。触发解密,根据服务器返回(可能是耗时差异,也可能是返回错误),判断填充是否正确;
这种模式进行攻击。
所以如果在第2步,减少触发解密步骤的频率,则可以提供攻击难度。

回顾加密传输的两种处理步骤:
(1)hash->padding->加密;
(2)padding->加密->hash;

对应的解密步骤:
(1)解密->去掉padding->检查hash;
(2)检查hash->解密->去掉padding;

第1种方案:第一步就触发解密,容易被攻击;
第2种方案: 如果hash检查失败,就不会往后走解密逻辑了,因此攻击的难度提高了 。

http和tcp层面的keepAlive机制

What: keepAlive机制是什么

HTTP层面

http1.0是短连接,每次http请求都建立tcp连接然后断开;(3次握手4次挥手)
http1.1为了优化性能,推出keepAlive机制,同域名的多个http请求可以复用同一个tcp连接,也就是让tcp连接不每次断开,keepAlive。
对于http协议来说,就是在header里标示这种需求: Connection: Keep-Alive

HTTP层面实际做的事情: header里标示keepAlive,然后通信双方不主动关闭tcp连接。
// http没有发送多余的保活、探活报文;

TCP层面

背景
TCP层面本来没必要做keepAlive,毕竟一个连接没有被关闭默认就是alive的。
但因为实际使用的时候,空闲的tcp连接会被 防火墙、负载均衡、代理软件 等中间节点掐断,
而且一般它们掐断空闲tcp连接的时候并不会向客户端发送任何报文提醒,因此客户端是无感知的。
这种情况可能产生 Broken Pipe错误。
为了避免这种情况,windows和linux内核在实现tcp的时候,加上了keepAlive机制。

TCP层面的keepAlive实际做的事情: 发送keepAlive报文,返回对端的存活性;
两个作用:

  1. 保活: 发送心跳报文,防止tcp连接被识别为空闲连接; // 防止被别人掐断;
  2. 探活: 探测对方的存活性,如果对方真的不在了,也不能浪费资源维持连接; // 主动自己断开;

本质上就是:要断开连接的话,还是自己来吧,不让别人代劳了,不然别人掐了也不告诉我,凭空增加了我的异常;

How: 如何配置tcp层面的keepAlive

保活: keepAlive间隔配置

linux内核tcp的keepAlive报文间隔默认是2小时; (net.ipv4.tcp_keepalive_time)
实际应该参考常见的”掐断连接凶手”的空闲连接配置,设置短一些。
比如:
F5: 空闲5分钟掐断;
GoogleCloud防火墙:空闲10分钟掐断;

所以可以考虑配置成5分钟keepAlive一次

探活: 主动断开相关的配置:

net.ipv4.tcp_keepalive_intvl: 对端不正常的话,多久重试1次; 比如75秒;
net.ipv4.tcpkeepaliveprobes: 最多重试几次以后断开; 比如9次以后主动断开;

Http层面KeepAlive配置

HTTP的头部:
1。 End-to-end头部:头部会被中间的代理原样转发;如Host等大部分Header;
2。 Hop-by-hop头部:只到下一跳节点;如KeepAlive头部;
因此要考虑Http请求链路上每一跳(比如Nginx)的KeepAlive配置,才能达到整个http请求涉及到的tcp连接都不自动断开。

HTTP层面的keepAlive和tcp层面的keepAlive关系

如果没有tcp层面的keepAlive,http层面希望的连接不断开,可能无法实现;
可能被中间的节点(比如防火墙、负载均衡、NAT代理)掐断。

如果tcp层面的keepAlive配置正确,http层面的keepAlive才能正常完成。

参考

https://support.f5.com/csp/article/K13004262
https://cloud.tencent.com/developer/news/696654