ES实战笔记

索引结构

ES底层的lucene引擎的分段,比较类似LSM tree的机制。
分段不可变,合并生成新的大的分段。
ES中的translog对应Hbase中的WAL日志,防止进程崩了丢数据;

常用api

查看分析器对某段文本的结果:

1
curl -XPOST 'localhost:9200/get-together/_analyze?analyzer=myCustomAnalyzer' -d 'share your experience with NoSqlιbig data technologies'

组合内置分词器和过滤器,空格分词、小写+反转:

1
curl -XPOST 'localhost:9200/_analyze?tokenizer=whitespace&filters=lowercase,reverse' -d  'share your experience with NoSql big data technolog es'

查看单文档的所有token信息:(get-together索引下、group类型、文档id为1):

1
curl 'localhost:9200/get-together/group/1/_termvector?pretty=true'

分析器

分析器 = 0到1个字符过滤器 + 1个单个分词器 + 0到n个分词过滤器;

标准分析器(默认)

standard analyzer = 标准分词器 + 标准分词过滤器 + 小写转换分词过滤器 + 停用词分词过滤器
(0字符过滤+1分词器+3分词过滤器)

简单分析器

simple analyzer: 在非字母处进行分词 + 转小写

空白分析器

whitespace analyzer: 根据空白分词 + 0分词过滤器

停用词过滤器

stop analyzer: 根据停用词分词 + 0分词过滤器;

模式分析器

pattern analyzer: 允许指定一个分词切分模式;

语言分析器

包括汉语;

雪球分析器

snowball analyzer: 标准分析器 + 雪球词干器;

分词器

标准分词器

主要处理欧洲语言,移除标点;

关键词分词器

整个文本提供给过滤器

字母分词器

基于非字母分词

小写分词器

非字母分词+转换成小写

空白分词器

通过空白来分词

模式分词器

例如可以在出现文本._.的地方分词:

1
2
3
4
5
6
7
8
curl -XPOST 'localhost:9200/pattern' -d '{
"settngs": {
”index” : {
”analysis”: {
”tokenizer": {
”patternl”: {
”type": ”pattern”,
”pattern”:”\\.-\\.”

UAX/URL电子邮件分词器

john.smith@example.com => 标准分词
=>
john.smith
example.com

http://example.com?q=foo => 标准分词
http、example.com、q、foo

如果用UAX/URL电子邮件分词器,则可以保留:
john.smith@example.com(type:< EMAIL>)
http://example.com?q=bar(type:< URL>)

路径层次分词器

path hierarchy tokenizer
输入: /usr/local/var/log/es/log
分词结果: /usr、/usr/local、 …. /usr/local/var/log/es/log
因此有相同父目录的路径搜索(分词有相同部分),能互相搜到。

分词过滤器

标准分词过滤器: 啥也不做;
小写过滤器、停用词过滤器、长度分词过滤器: 将最短和最长的单词过滤掉(自行设置min\max);
截断分词过滤器: 截断超出长度token;
修建分词过滤器: trim
限制分词数量分词过滤器: 限制最多多少个token被索引,比如设置max=8;
reverse分词过滤器: 把token反转,可以用于支持后缀索引;
唯一分词过滤器: 每个单词只保留第一次出现的位置(去重了)
ascii折叠分词过滤器: 尽量转ascii
同义词分词过滤器: 转成同义词
ngram过滤器: 略
滑动窗口分词过滤器: 略

提取词干

这个好像只是英文有用。把单词缩减到词根。
administrations -> administr
词干提取器: snowball,porter_stem,kstem
字典提取词干: hunspell分词过滤器+字典

打分相关

  1. TF-IDF: 词频、逆文档频率
  2. Okapi BM25;
  3. 随机性分歧: DFR相似度
  4. IB相似度;
  5. LM dirichlet相似度;
  6. LM Jelinek Mercer相似度;

BM25

1
2
3
4
5
6
7
8
9
10
11
12
{
"mappings":{
"get-together": {
"properties": {
"title":{
"type":"string"
,"similarity": "BM25"
}
}
}
}
}

BM25的3个重要参数:
k1: 数值, 词频的重要性; (默认1.2)
b: 0~1数值, 篇幅对于得分的影响程度; (默认0.75)
discount_overlaps: 多个分词出现在同一位置,是否影响长度的标准化(默认true)

boosting: 加权

可以用来修改文档相关性的程序。
包括:

  • 索引期boosting
  • 查询期boosting

一般使用查询期boosting(避免重新索引全部文档)

相关性、语义搜索的一些方案

首先所有的词向量模型都是基于分布假说的(distributional hypothesis):拥有相似上下文的词,词义相似。

参考: https://zhuanlan.zhihu.com/p/80737146

  1. word embedding
  2. sentence embedding: 更难训练;

word embedding算法:
word2vec:Skip-gram模型训练神经网络以预测句子中单词周围的上下文单词。
GloVe:单词的相似性取决于它们与其他上下文单词出现的频率。该算法训练单词共现计数的简单线性模型。
Fasttext:Facebook的词向量模型,其训练速度比word2vec的训练速度更快,效果又不丢失。

网上现有的预训练模型:基于维基百科语料库.

性能更优的方案:

  1. 粗排: ES;
  2. 精排: 语义模型计算相似度;

工业界主流: 谷歌的bert模型

中文分词IK相关

https://github.com/medcl/elasticsearch-analysis-ik

索引时优先使用analyzer配置的分词器,对文档进行分词;
// 索引时用ik_max_word,尽量多分几个词出来;
查询时优先使用search_analyzer配置的分词器,对输入进行分词;
// 查询时使用ik_smark, 尽量用最长的token去查询;
//

1
2
3
4
5
6
7
8
9
10
11
curl -XPOST http://localhost:9200/index/_mapping -H 'Content-Type:application/json' -d'
{
"properties": {
"content": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
}
}

}'

实践遇到的问题

分词查询和关键字查询同时使用

分词查询的时候,切分是ik_max_word,最大只切到单词;
比如“工具”就是最小粒度了,因此如果查询的时候使用”工”则不会查询到结果。

如果是默认的标准分词器,则只会有单个字,不会有单词;

所以如果两个都要支持,可以用两个字段,(存两个字段)
一个字段用 ik_max_word, 一个字段用 standard;
查询的时候也是用bool or 连接,命中一个即可。

可参考的解决方案

用fields多加一个不分词的结果(name.raw)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"properties":{
"name": {
"type": "string",
"analyzer": "standard",
"fields": {
"raw": {
"index": "not_analyzed",
"type": "string"
}

}

}
}

}

整合网上的近义词库

思路1: 自定义一个分词过滤器;// 可复用程度高
思路2: 写入该字段前,先用网上的近义词库把文本解析成空格分割的token,然后用空白分词器索引;(查询时用分词器) // 灵活,不用跟版本
// 由于可以配置多个分词过滤器,所以可以同时配置空格分词过滤器和同义词分词过滤器

维基百科近义词库: 528MB
http://licstar.net/archives/tag/wikipedia-extractor

某个领域最好的词向量:
http://licstar.net/archives/tag/%e8%af%8d%e5%90%91%e9%87%8f

考虑用boost加入相似度因素;(加权)

词向量资料:
http://licstar.net/archives/328

聚集

聚集有几个选项:

桶型聚集: (group by)

term: 词条聚集,就是统计文档数量;
significant_terms: 显著聚集
range: 范围聚集;
histogram: 直方图聚集;(类似范围,但是只需要提供间距即可)
嵌套聚集、反嵌套聚集、子聚集: 根据文档关系聚集;
地理距离聚集;

度量型聚集: (agg)

stats: 就是统计min,max,avg,count,sum信息;
extended_stats: 就是加上标准差这种更冷门的统计信息;
percentile: 分位数(近似,可以用compress参数控制精度和内存消耗)
cardinatily: 基数,也就是uv;// 近似的,hyperLogLog++, precision_threshold控制精度

过滤器和后过滤器

过滤器

后过滤器:

两者区别

文档->过滤器->查询->后过滤器->查询结果
文档->过滤器->查询->filter聚集->聚集结果

换句话说就是后过滤器不影响聚集,过滤器则影响聚集结果。
filter聚集则只影响聚集。

有一个例外是使用globel聚集,这样即使符合查询的只有2条文档,聚集也会应用到所有的文档上。(聚集比查询结果的数据源大)

文档间的关系

对象类型

输入:

1
2
3
4
5
6
7
8
9
10
11
{
"name": "name1"
,"events": [
{"title": "hadoop"
,"date”: "12月"
}
,{"title": "es"
,"date”: "6月"
}
]
}

这种数据实际索引的时候,会把各个字段分别组成数组:

1
2
events.title: ["hadoop","es"]
events.date: ["6月",“12月”]

所以搜的时候如果想搜6月的hadoop, 也可以搜出12月hadoop的文档(name1).

嵌套类型

上面的情况可以用嵌套类型解决。
这个时候的索引:

1
2
3
4
5
6
7
[events.title: hadoop
events.date: 12
,
events.title: es
events.date: 6

]

父子关系和反规范化

父子关系的存储:
1.规范化:父文档和子文档分开存储,然后再存储一个映射关系;// 相关查询: has_parent/has_child
2.反规范化:子文档中存储父文档;(空间换时间)

嵌套json的存储

由于ES的底层Lucence只支持扁平结构,ES支持嵌套json的方法是通过强行打平,如:

1
2
3
4
5
6
7
{
"title": "titl1"
,"location":{
"name": "name1"
,"geolocation": "51.52,-0.09"
}
}

实际实施到Lucence层的时候是这样存的:

1
2
3
titile: "title1"
location.name: "name1"
location.geolocation: "51.52,-0.09"

因此我们设计的key一定不要有小数点符号。
而且最好是一对一关系(不是数组)。

父子关系的索引选项:
include_in_parent/include_in_root

一对一

嵌套json/对象

一对多

嵌套文档: 索引阶段进行join; // 同分片存储,保证本地连接
父子关系: 查询阶段进行join; // 不同分片,远程连接

多对多

反规法化: 可以处理多对多关系

ES扩展

ES集群使用master-slaver架构,master和slaver用心跳信息来判断彼此的存活;
(有点类似hadoop,不知道是不是也有hadoop的HA;hadoop在120个节点的时候namenode容易OOM,不知道ES有没有类似问题)
master\slaver互相ping应该会消耗一些带宽,可以考虑调节心跳频率调节性能。

节点下线:先停用(停止数据写入、迁移)

集群升级

重启

直接关闭整个集群,不可用。
然后升级所有节点,重启集群。
有一段时间不可用。

轮流重启

不牺牲可用性的情况下,重启集群;
基本步骤是:关一个节点,升级一个节点,重启这个节点,重新加入集群。
这里有一个关键就是,关闭某个节点的期间,不需要集群自己做rebalance.
因此配置:cluster.routing.allocation.enable=none
可以用curl发命令修改这个配置。
过后重新设置为all

如果副本数>1,上述操作期间服务依然可用。

别名API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
curl -XPOST 'localhost:9200/_aliases' -d '
{
"actions": [
{
"add": {
"index": "get-together",
"alias": "gt-alias"
}
},
{
"remove": {
"index": "old-get-together",
"alias": "gt-alias"
}
}
]
}'

可以分拆成两个命令:

1
2
curl -XPUT 'http://localhost:9200/get-together/_alias/gt-alias'
curl -XDELETE 'http://localhost:9200/old-get-together/_alias/gt-alias'

一个别名可以指向多个索引,甚至指向logs-开头的索引。
(类似于一个逻辑名称)
别名还可以附带一个过滤器。

路由

默认路由策略:文档id
可以手动指定routing=xxx来影响分片行为。

因此可以根据业务,把一起访问的文档路由到同一分片上。

debug api: 查看搜索的分片

routing为xxx时,会搜索哪个分片:

1
curl -XGET 'localhost:9200/get-together/_search_shards&routing=xxxx'

可以在别名中配置路由,简化查询操作。

性能优化

合并请求(bulk接口)

批量新增、批量更新、批量搜索

IO配置优化

segments: 分段;

ES接收到文档后:
分段的倒排索引

概念

refresh: 刷新; 生效,重新打开索引;新建的索引生效, 以前的缓存失效;
flush: 冲刷; 刷盘,索引数据写入磁盘;
合并: 小分段合并成大分段; // 分段越多,查询越慢;
存储限流: 调节每秒写入的字节数;

优化策略

刷新频率降低

缓存失效的频率降低,性能更高,新文档慢一些生效;
// 默认每秒刷新, index.refresh_interval

刷盘频率降低

IO消耗降低,性能更高,丢数据概率提高;
触发刷盘的时机:
1.内存缓存区已满;
2.固定间隔(定时器);
3.事务日志达到阈值;
因此调控的手段:
1.内存缓存区大小: indices.memory.index_buffer_size;
2.刷新间隔: index.translog.flush_threshold_period;
3.事务日志大小: index.translog.flush_threshold_size;

合并策略优化

合并的作用:

  1. 真正删除文档;
  2. 分段越少,查询越快;

触发合并的时机:

  1. 索引文档;
  2. 更新、删除文档;

合并相关配置:
index.merge.policy.segments_per_tier:
每层的分段数量;
高=>写性能越好,越低=>读性能越好;
index.merge.policy.max_merge_at_once:
每次合并多少分段; 设置为等于segments_per_tier即可;
index.merge.policy.max_merged_segment:
分段的最大规模;
低=>写性能好; 高=>读性能好;
index.merge.scheduler.max_thread_count:
合并用的最大线程数;

存储限流

indices.store.throttle.max_bytes_per_sec:
最大IO吞吐量,默认20MB/s,默认只针对merge(合并分段);
(ssd的话,可以调大到100~200MB)

indices.store.throttle.type:
限流类型;none: 不限流, all: 所有磁盘操作; 默认: merge

磁盘IO优化

MMAPDirectory:
进程请求OS对磁盘文件进行内存映射(初始化开销);
也就是mmap,0拷贝,进程挂掉的话,内核会帮忙保存文件;

NIOFDirectory:
进程将磁盘文件复制到JVM堆中;
也就是常规文件访问,进程挂掉,则文件修改丢失;

相关配置:
index.store.type: 默认default.
mmapfs: 只使用MMapDirectory, 静态索引,物理内存能放下索引时适用;
niofs: 只使用NIOFSDirectory,32位系统适用;

可以对单个索引配置,也可以配置成全局。

缓存优化

ES的缓存分为:

  1. 分片查询缓存: 缓存查询结果;
  2. OS缓存: 缓存索引到内存;

过滤器缓存

过滤器缓存可以在query时,在filter用_cache:true/false配置。
过滤器缓存在各个节点上,内存占比配置:
indices.cache.filter.size: 默认10%
缓存淘汰策略: LRU
缓存生存时间:
index.cache.filter.expire: 30m (表示30分钟过期)

比较简单的过滤器可以使用bitset来减少内存消耗;
比较复杂的过滤器则直接存储查询结果。
可以使用bitset的过滤器:
term,exists/missing,prefix

字段过滤器

索引:token -> 文档 ; (又叫倒排索引)
字段: 文档 -> 词条; (主要用于排序和聚集)

字段上可以用的过滤器:
terms过滤器
range过滤器

预热器优化

可以在索引上定义预热器。
即将按日期倒序查询:

1
2
3
4
5
6
7
8
curl -XPUT 'localhost:9200/get-together/event/_warmer/upcoming_events' -d '{
"sort":[
{
"date": {"order":"desc"}
}
]

}'

即将查询热门分组:

1
2
3
4
5
6
7
8
9
curl -XPUT 'localhost:9200/get-together/group/_warmer/top_tags' -d '{
"aggs":{
"top_tags":{
"terms":{
"field": "tags.verbatim"
}
}
}
}'

可以创建索引的时候直接定义预热器。

脚本优化

如果只有数值型的操作,可以考虑用lucene表达式代替脚本;
性能:
不用脚本>lucene表达式(js)>java脚本>其他脚本

ES查询过程

topN查询

比如取10个结果:
1.每个分片取得分前10的;
2.合并所有分片的结果(归并排序);

因此得分是在每个分片上算的(分片内得分)

分页查询

{
“from”: 400
,”size”: 100
}
这种需要400~500的结果,但实际会取前500个,然后扔掉前400个。

如果是顺序翻页需求,可以用scroll类型查询优化,查询的时候传递一个scroll=1m的参数,让ES等一分钟,以准备接收下一次翻页。这种情况下,每次ES都会返回一个scrollId。

集群管理

模版

可以配置某个前缀的索引都应用某个配置模版。
(比如模版里定义别名)

多个模版的合并

多个模版可能匹配到同一个索引,这个时候多个模版的配置会合并。
合并的顺序按照order字段,0的先执行,然后1的覆盖0的,依此类推。

tcp调优

原理:linux如何处理新的tcp连接

  1. 触发时机: 客户端: 发起一个tcp连接(SYN);

服务端:
(相关参数: net.ipv4.tcp_max_syn_backlog)
TCP模块查看max_syn_backlog是否超阈值;
超阈值的话: 根据tcp_abort_on_overflow是丢弃还是reset;
未超的话: 放到半连接队列;

  1. 触发时机: 客户端: 回复服务端ACK
    服务端:
    (相关参数: net.core.somaxconn)
    完全建立连接: 放到全连接队列;

内核调优

  1. vim /etc/sysctl.conf 在末尾添加:
1
2
3
4
net.core.somaxconn = 16384 # ESTABLISHED的连接队列最大
net.ipv4.tcp_max_syn_backlog = 65536 # SYN半开连接队列最大
net.core.wmem_default=8388608 # 默认发送窗口的字节大小,还有一个最大值的参数
net.core.rmem_default=8388608 # 默认接收窗口的字节大小,还有一个最大值的参数
  1. sysctl -p /etc/sysctl.conf

  2. echo "1" > /proc/sys/net/ipv4/tcp_abort_on_overflow # 满了以后显式发送RST包给客户端

  3. 修改nginx配置

    1
    2
    3
    listen 80 backlog=16384;

    listen 443 backlog=16384;

    就是server里面的配置,在80 后面加上backlog=16384

  4. cd tnginx_1_0_0-1.0/bin/nginx -s reload

  5. 重启api服务(如果有)

  6. 确认配置生效 ss -lnt

相关内核commit

since Linux 5.4 it was increased to 4096 https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=19f92a030ca6d772ab44b22ee6a01378a8cb32d4

查看现有配置:

1
sysctl -a  | grep somaxconn

jdk11下g1收集器使用

WHAT: g1是什么?

g1是一个jdk9推荐默认使用的垃圾收集器。

WHY: 为什么要使用g1

主要优点:收集高吞吐、没有内存碎片、收集时间可控。

G1出来之前,OLTP应用之前一般使用CMS收集器,达到暂停时间短的效果。
CMS: https://www.jianshu.com/p/fed80fdba376

CMS收集器的缺点:
1.有内存碎片: 标记清理算法容易留下碎片,可以用参数在几次full gc以后进行一次压缩;-XX:CMSFullGCsBeforeCompaction=0: 每次都压缩;
2.full gc风险(foreground): 业务线程请求分配内存,但是内存不够了,于是可能触发一次CMS GC,这个过程就必须要等待内存分配成功后业务线程才能继续往下面走,因此整个过程必须STW,所以这种CMS GC整个过程都是STW,相当于full gc了;

cms触发回收: -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly: old区占75%的时候满足回收条件,检查这个条件的线程:-XX:CMSWaitDuration=2000(默认2秒)。

g1以前的大部分收集器包括cms,都需要程序员手动设置新生代和老生代,g1则会自动调节eden region和old region的占比,以达到设定的暂停时长目标,因此更加智能。
g1的目标是取代CMS,因此我们有必要了解一下g1。

HOW: G1怎么工作的、原理

g1以前的收集器是新生代+老生代的布局,g1则是先分<=2048个region,然后这些region可以用于eden region\old region\humongous region\suvivor region。
相当于空间布局更加细致了。

eden: 和以前的新生代eden类似;
old: 和以前的老生代类似;
humongous:和以前的大对象直接进old区类似;
suvivor: 和以前的双缓冲区类似。

g1可以通过统计信息,动态调节eden region和old region的比例,达到设定的暂停时长目标。

G1中的关键过程

G1垃圾收集器工作过程中有几个关键的过程:
1.对象分配: 有内存使用才有内存的回收;
2.YGC;
3.全局并发标记周期;
4.Mixed GC;
5.FGC: 具体实现不是G1负责,只是作为兜底使用。

对象分配

大部分到eden,少部分直接到 Humongous region, 统计时算作old区占用。
Humongous: >=0.5*region size的对象会直接分配一个独立的Humongous region。
相关参数: -XX:G1HeapRegionSize=16M

YGC

STW, 并发复制,一部分到old(年龄); Ygc末尾,重新计算edan区,survive区大小

触发时机

eden region用完。

全局并发标记周期

即concurrent marking cycle: 主要为old region/Humongous region回收服务。

1.初始标记

STW, initial mark,找根;

这个过程需要跟混YGC,在YGC的末尾触发,因为需要STW,而YGC也需要STW,为了减少STW的次数,就让初始标记阶段直接跟混YGC的STW。

理由也很简单,因为正常的服务YGC肯定比较频繁,比OLD区满的频率大多了。而且大部分增大old区的对象都在YGC之后从young区移动到old region,除了直接分配的大对象之外。而大对象毕竟是小概率、低频事件。

2.并发扫描

并发扫描从新生代出发的直接可达性,完成后才能下一次YGC,不然白扫了

3.并发标记

扫描间接可达性;

4.最终标记

STW, 避免浮动垃圾;

5.并发清理

STW, 回收完全空闲的region。(比如直接回收Humongous region)
这个过程不会产生碎片,因为是整个region回收的。
并不是完全空闲的region的处理: 交给Mixed GC。

Mixed GC

触发时机

标记阶段结束后,可以知道old region有多少可以被回收;YGC之后,看浪费占比(可回收未回收),太浪费的话就MixedGC;

相关参数:

  1. G1HeapWastePercent=10: YGC之后,看浪费占比(可回收未回收),太浪费的话就MixedGC;
  2. G1MixedGCLiveThresholdPercent=85: 超过85%占用率的region就不加入CSet了,性价比太低;
  3. G1MixedGCCountTarget=8: 连续可以进行多少次MixedGC;
  4. G1OldCSetRegionThresholdPercent: 一次Mixed最多选多少region进CSet;

FGC

G1不直接提供full gc,< jdk10是调的serial old,我们用的jdk11是调并发的收集,都需要STW。

触发时机

由于标记阶段不能进行Mixed GC,所以如果标记阶段堆被塞满了,就会触发FGC。(一般是非STW阶段,也就是第二阶段并发扫描和第三阶段并发标记,因为STW阶段不会分配新的对象)

Evacuation

非完全空闲的region的处理,都是压缩复制到另一个region,G1称这个为Evacuation。(翻译过来就是对象的疏散)

小结

从上述过程可以总结占用、增多各种region的事件;回收、减少各种region的事件。
eden region增多: 普通小对象的分配;
eden region的减少: YGC;
Humongous region的增多: 大对象的分配,直接进H region;
Humongous region的减少: concurrent marking cycle的最后阶段(并发清理),回收完全空闲的region;
old region的增多: YGC末尾,随着对象年龄晋级到old region;
old region的减少: concurrent marking cycle的最后阶段(并发清理),回收完全空闲的region; 以及Mixed GC: 回收部分占用的region。

实战

环境配置

容器CPU: 4
容器内存: 8G
JDK: 11
JVM参数: 未设置

现象

通过命令:

1
2
nohup jstat -gcutil <pid> \
> gcutil.log 2>&1 & echo $! > gcutil.pid

发现old区增长比young区快。

执行命令:

1
jmap -histo:live <pid> | head

触发FGC、STW,内存占用突然掉下来。

观察内存自然回收前一刻的内存占用,可以看到old区占用大概为43.49%。而默认的参数-XX:InitiatingHeapOccupancyPercent=45
G1这里的InitiatingHeapOccupancyPercent指的是old区占整个堆的比例,和CMS的不同。也有资料认为这个是指整个堆的占用/总大小,但其实统计的时候都统计的是YGC以后的堆的占用/总大小,
也就等于old区的使用/整个堆的大小。

所以这种情况下young区增长比old区快的原因是大对象的分配。
大对象region的清理依赖标记阶段最后的清理,而标记阶段的初始mark依赖YGC的末尾。

最后因为没有几乎YGC,所以大对象很容易就一直堆积着、而且由于young区太大了,old区的占用很高也触发不了标记阈值。

调优参数

如果要维持old区在gcutil视角下不超过90%,可以通过简单的计算,将InitiatingHeapOccupancyPercent调整为37%。(仅当前case)

1
-Xmx4096M -Xms4096M -XX:G1HeapRegionSize=8M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -Xlog:gc*:gc.log -XX:InitiatingHeapOccupancyPercent=37

调整以后old区维持98%, YGC次数增多。
但这个参数仅第一次有用,因为之后G1会根据暂停时间目标来调整不同region的比例,因此并不能长期解决。

调参2

我们这个case因为是空转服务,而且Eden区基本不使用,光怼Old区了(大对象)。可以通过调节大对象阈值,让Eden区增长速度稍微快于Old区(IHOP为45%)。

相同参数: -Xmx4096M -Xms4096M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -Xlog:gc*:gc.log

各种姿势的结果:

思路 gc耗时(ms) YGC Mixed GC FGC 差异参数
拒绝大对象 2260 31 0 0 -XX:G1HeapRegionSize=16M -XX:InitiatingHeapOccupancyPercent=37
拒绝大对象 5390 52 0 0 -XX:G1HeapRegionSize=32M -XX:InitiatingHeapOccupancyPercent=37
停顿小 5688 128 0 0 -XX:G1HeapRegionSize=8M -XX:InitiatingHeapOccupancyPercent=37 -XX:MaxGCPauseMillis=50
不让调IHOP 3507 128 1 0 -XX:G1HeapRegionSize=8M -XX:InitiatingHeapOccupancyPercent=37 -XX:-G1UseAdaptiveIHOP

下面解释4个试验结果:

结果1: 16M的region,所以大于8M的对象会分配一个Humongous region,否则在edan区分配,由于Humongous region也在统计中算到了old region里头,所以我们这样操作以后会减少统计结果中old区的占用率。16M配置下,看gc.log已经完全没有了Humongous region,因此这样配置就肯定不会触发报警了。

结果2:32M的region,类似于16M的,0个Humongous region,但由于region比较大,碎片多,比较浪费, YGC比较多,gc耗时比较大;

结果3:由于G1会动态调整young区\old区的大小比例,young在5%~60%之间调整,如果young在60%,那么即使old区填满了也才40%,无法达到IHOP,也就无法通过IHOP触发标记阶段,只能通过YGC触发;因此这里我们把自动调整的关掉,这样young区就不会太大,就能触发IHOP的阈值了。这种情况下由于region大小没有调大,YGC次数没有太大变化;而由于没有使用自动调整IHOP,old区很满以后会触发标记阶段,然后G1发现回收young以后,浪费的空间仍然大于G1HeapWastePercent参数,于是就进行Mixed GC,回收old区。所以这种配置下有1次MixedGC。

结果4:这个思路也比较类似思路3,由于G1是根据停顿时间来调整young区/old区大小的,我们把停顿时间设定得超小,它就会把young区变小已达到停顿时间的要求,同时也会更频繁得YGC,Humongous可以在标记阶段的末尾得到清理,所以这种配置下也不会触发Old区的长期占用高的报警。同时由于停顿时间超小,YGC次数变得很高,总耗时也很高。

以上四种配置,真正在线上只有配置1是实用的。因此如果理解了G1的过程,其实调参可以只调很少。

其他: Metaspace

除了上述区域,还有一个元数据区域。通过jstat -gcutil可以看到它的占用率一般是在98.1%左右,比较高。但实际上元数据区的占用率是越高越高的,因为它的实际含义类似于(1-碎片率),占用率98.1%,差不多相当于碎片率是1.9%。因为程序工作一段时间后,metaspace基本就不怎么增长了,所以基本不用我们操心,最多只需要将初始metaspace的大小设大一点,避免它增长到稳定之前触发full gc。可以先启动一次,看看稳定的时候metaspace的大小,然后向上取2的幂,设置大小。不要以为把这个值设大2倍,占用率会下降一半,metaspace是用多少申请多少的,所以即使设大了也不会影响占用率的。

1
-XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M

元数据相关参见: http://lovestblog.cn/blog/2016/10/29/metaspace/

参考资料

https://www.oracle.com/cn/technical-resources/articles/java/g1gc.html
https://www.jianshu.com/p/0f1f5adffdc1
https://tech.meituan.com/2016/09/23/g1.html

mysql可重复读下用for update导致的死锁

背景

试图用代码(JVM进程)来维持某列的唯一性。

实例

以下代码在可重复读隔离级别下执行,表中原来只有1~10的rank。假如A、B两个事务同时试图插入rank为21和22的rank;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional
public void createBannerDeadlock(Banner banner) {
banner.recordCreate();
List<Banner> rankList = cscCenterBannerDAO.selectForUpdate(banner.getRank());
// 无记录,A占据间隙锁rank(10,+), B占据间隙锁 (10,+),gap锁之间不互斥
if (!rankList.isEmpty()) {
throw new RuntimeException("记录已存在");
}
try {
logger.info("睡5秒钟");
Thread.sleep(MILLIS);
} catch (InterruptedException e) {
throw new RuntimeException("中断");
}
logger.info("开始插入: {}", System.currentTimeMillis());
cscCenterBannerDAO.insert(banner); // 都申请插入意向锁, 都需要等待别人的间隙锁释放;死锁
}

1.如果rank上没索引:
两个事务第一个for update就会互相阻塞(锁全表),串行执行,没有死锁,两个插入都成功;
2.如果rank上有索引(普通索引或者唯一索引):
两个事务第一个for update不会互相阻塞(锁区间10~正无穷),并行执行,有死锁,到最后真正插入的时候,两者都需要插入意向锁,然后都等待对方的gap锁释放,死锁;等待很久以后,一个成功一个失败(一个事务被回滚);

死锁异常:

1
Deadlock found when trying to get lock; try restarting transaction;

原理

两个原因导致了有索引的时候反而会死锁:
1.select for update的间隙锁之间不互斥,两个事务都能获得gap锁;
2.插入意向锁需要等待gap锁释放;

间隙锁相关: http://xiaoyue26.github.io/2018/05/26/2018-05/MVCC%E4%B8%8E%E9%97%B4%E9%9A%99%E9%94%81-mysql%E6%8B%BE%E9%81%97/
意向锁相关: http://xiaoyue26.github.io/2018/12/24/2018-12/mysql%E6%84%8F%E5%90%91%E9%94%81/

select for update的结果

即使是同样的语句,也有可能有不同的加锁结果。
1.如果有唯一索引,命中了唯一记录: 行锁,互斥锁;
2.如果有唯一索引,没命中: gap锁,另一个事务也可以获得这个gap锁,但是不能插入数据;(后续有死锁可能)
3.如果有普通索引,命中了记录: 行锁+gap锁;(后续有死锁可能)
4.如果有普通索引,没有命中记录: gap锁,和情况2相同;(后续有死锁可能)
5.如果没有索引,直接锁全表,互斥,直接阻塞别的事务。(mysql的行锁是依赖索引的,这一点和oracle锁在数据块上不同;
因此如果没有索引或者没有用上索引,mysql就只能加表锁了。)

可见如果where条件没有命中记录的时候,如果有索引,反而可能有死锁风险。
除了select for update,其他进行当前读的语句(如delete)也可以获得gap锁,因此也有同样的烦恼。

那么都有哪些语句能获得gap锁呢?

参考

https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html#innodb-gap-locks

gap锁

首先当然要可重复读和串行读才有gap锁。
然后根据上面的参考资料,gap锁只有一个目标:
就是防止别的事务在这个区间插入数据。

因此不同事务可以获得同一个区间的gap锁,因为这与上述目标并不冲突。两个事务可以获得这个区间的gap-S锁,gap-X锁,都可以。这一点和意向锁有点类似,只不过意向锁是加在表上的,gap锁是加在区间上的。

gap-S锁和gap-X锁没有区别。(因此select in share mode和select for update获得的gap锁本质是一样的,想要无锁读,就直接用select进行快照读。)

select in share mode/select for update/update/delete这4种语句是可能获取gap锁的。因此这4种当前读如果没有命中记录,而且又用到了索引,就会给死锁埋下风险。

RC的优势

RC下: 扫描过但不匹配的记录不会加锁,或者是先加锁再释放,即semi-consistent read;
RR下: 扫描过记录都要加锁。

RC的缺点

1.RC有幻读;
2.RC需要搭配row模式binlog;

mysql5.0以前的statement模式binlog和RC搭配有bug,因此为了兼容性,一般默认隔离级别RR,binlog模式row。

因此:
RC搭配row模式binlog;
RR搭配statement模式binlog;

RC和RR读的区别

RR下,事务在第一个Read操作时,会建立Read View
RC下,事务在每次Read操作时,都会建立Read View

所以:
读已提交: 总是读到最新提交的数据;
可重复读: 总是读到和第一次读的时候相同的数据,与事务开始时间无关。

innodb的7种锁类型

参考这里: https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html

行的读锁 S
行的写锁 X
表级:读意向锁 IS
表级:写意向锁 IX
(意向锁只影响和辅助表级操作,毕竟行级操作直接定位到具体行了,不需要意向锁的帮助。)
记录锁: Record Lock: 在索引上加锁。
gap锁: 区间内阻止插入的锁,当前读可能触发。
next-key锁: 记录锁+gap锁;
插入意向锁: 同区间insert可以并发,只要不重复;
自增锁: 如果是严格增模式,自增id会导致事务串行;

查看当前锁的信息

InnoDB整体状态,其中包括锁的情况:

1
show engine innodb status

只看锁信息:
mysql8.0.1以前:

1
2
-- 有事务在等的锁:
select * from information_schema.innodb_locks;

mysql8.0.1以后:

1
2
-- 所有的锁:
SELECT * FROM performance_schema.data_locks

两者字段的对应关系: https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-table.html

BeanCopier测评报告

What: BeanCopier是什么?

本文讨论的BeanCopier具体指的是:
org.springframework.cglib.beans.BeanCopier
, 此外用于对比的BeanUtils指的是:
org.springframework.beans.BeanUtils

其中spring使用5.1.1.release,所以cglib版本为3.2.10.

BeanCopier和BeanUtils都能用于对象之间浅拷贝成员字段。

Why: 背景

这里引用一下网上的说法:

在做业务的时候,我们有时为了隔离变化,会将DAO查询出来的Entity,和对外提供的DTO隔离开来。大概90%的时候,它们的结构都是类似的,但是我们很不喜欢写很多冗长的b.setF1(a.getF1())这样的代码,于是我们需要BeanCopier来帮助我们。选择Cglib的BeanCopier进行Bean拷贝的理由是,其性能要比Spring的BeanUtils,Apache的BeanUtils和PropertyUtils要好很多,尤其是数据量比较大的情况下。

性能测评

参考网上一些benchmark,如https://juejin.im/post/5dc2b293e51d456e65283e61
beanCopier能比beanUtils快30~45倍。

场景 耗时 原理
直接使用get&set方法 22ms 直接调用
使用BeanCopiers(不使用Converter) 22ms 修改字节码
使用BeanCopiers(使用Converter) 249ms 修改字节码
使用BeanUtils 12983ms 反射
使用PropertyUtils(不使用Converter) 3922ms 反射

因此如果我们不使用类型转换,使用BeanCopiers几乎没有性能损耗。这是因为cglib修改生成的字节码和get&set几乎是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MA$$BeanCopierByCGLIB$$d9c04262 extends BeanCopier {
public MA$$BeanCopierByCGLIB$$d9c04262() {
}

public void copy(Object var1, Object var2, Converter var3) {
MA var10000 = (MA)var2;
MA var10001 = (MA)var1;
var10000.setBooleanP(((MA)var1).isBooleanP());
var10000.setByteP(var10001.getByteP());
var10000.setCharP(var10001.getCharP());
var10000.setDoubleP(var10001.getDoubleP());
var10000.setFloatP(var10001.getFloatP());
var10000.setId(var10001.getId());
var10000.setIntP(var10001.getIntP());
var10000.setLongP(var10001.getLongP());
var10000.setName(var10001.getName());
var10000.setShortP(var10001.getShortP());
var10000.setStringP(var10001.getStringP());
}
}

自测

1kw次 1亿次
beanUtils 8秒 91秒
beanCopier(无converter/有缓存) 0.5秒 4秒
beanCopier(无converter/无缓存) 1.1秒 10秒
beanCopier(无converter/懒汉式缓存) 3.3秒 30秒

其中各个测试的相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. beanUtils:
BeanUtils.copyProperties(bean, vo);

// 2. beanCopier(无converter/有缓存):
public static final BeanCopier MODEL_2_VO = BeanCopier.create(Banner.class
, BannerVO.class, false);
copier.copy(bean, vo, null);

// 3. beanCopier(无converter/无缓存):
copier = BeanCopier.create(Banner.class, BannerVO.class, false);
copier.copy(bean, vo, null);

// 4. beanCopier(无converter/懒汉式缓存):
public static final Map<String, BeanCopier> MAP = new ConcurrentHashMap<>();
copier = MAP.computeIfAbsent(key, k -> BeanCopier.create(Banner.class
, BannerVO.class, false));
copier.copy(bean, vo, null);

耗时组成

BeanUtils耗时组成:(主要为反射)

BeanCopier(有缓存、无convert)耗时组成:(主要为调用构造函数(xxx::new))

beanCopier(无converter/懒汉式缓存): 生成key和查询缓存花费了大量的时间,因此第四种写法是得不偿失的。

总结

BeanCopier(无convert、有缓存): 主要耗时是业务自身的代码(创建对象),性能最优,可以考虑;

BeanCopier(无convert、无缓存):不需要预创建,写法简洁,耗时增加不多,可以考虑。

BeanUtils: 反射调用占用了60%的代码,其中还涉及到查询concurrentHashMap中的bean定义,损耗较大。

How: 用法

BeanCopier: 只拷贝名称和类型都相同的属性, 基本类型和装箱类型视为不同类型。

如果不符合上述规则,可以自定义converter。(否则可以将converter字段传null)

示例代码:

1
2
3
4
5
6
public static final BeanCopier MODEL_2_VO = BeanCopier.create(Banner.class
, BannerVO.class, false); // 可以复用一个copier,提高一倍速度

banner = ... ; // 例如从DAO获取到
BannerVO vo = new BannerVO();
MODEL_2_VO.copy(b, vo, null); // converter可以直接传null

支持功能

情况 Apache BeanUtils Cglib BeanCopier Spring BeanUtils
非public类 不支持 支持 支持
基本类型与装箱类型,int->Integer,Integer->int 支持,可以copy 不支持,不copy 不支持,不copy
int->long,long->int,int->Long,Integer->long 不支持 不支持 不支持
源对象相同属性无get方法 不支持 不copy 不支持 不copy 不支持 不copy
目标对象相同属性无get方法 支持 不支持 支持
目标对象相同属性无set方法 不copy,不报错 报错 不copy,不报错
源对象相同属性无set方法 支持 支持 支持
目标对象相同属性set方法返回非void 不设置,其他正常属性可以copy 不设置,导致其他属性都无法copy 支持,能够copy
目标对象多字段 支持 支持 支持
目标对象少字段 支持 支持 支持

此外一些较为复杂的情况BeanCopier会进行浅拷贝:

1.属性为对象;

2.属性为List<自定义类>;(注意范型的类型擦除)

当然前提还是源类和目标类中该属性的类型相同,如果不同只能自定义converter了。相应生成的字节码:

1
2
3
4
5
6
public void copy(Object var1, Object var2, Converter var3) {
BeanB var10000 = (BeanB)var2;
BeanA var10001 = (BeanA)var1;
var10000.setAList(((BeanA)var1).getAList());
var10000.setName(var10001.getName());
}

因此不能用BeanCopier做深拷贝。

对应我们考虑的场景,entity和VO之间拷贝数据,由于entity和VO一般不包含集合或者对象,而且没有修改数据的副作用,因此还是可以用的。

线程安全

copy方法

BeanCopier实例的copy方法是线程安全的,因为它是无状态的,相关讨论:https://cglib-devel.narkive.com/2cqPSUM1/cglib-and-thread-safeness

create方法

BeanCopier的create方法底层会缓存生成过的字节码,因此不是无状态的,但是有用到synchronized进行线程安全的保护:

1
2
3
4
5
6
7
8
9
10
11
protected Object create(Object key) {
Class gen = null;
synchronized (source) {
ClassLoader loader = getClassLoader();
Map cache2 = null;
cache2 = (Map) source.cache.get(loader);
...
}
/** 3.根据生成类,创建实例并返回 **/
return firstInstance(gen);
}

由于BeanCopier的create方法需要查询底层map中的缓存,因此当它生成过的copier非常多的时候,有理由猜测create性能会下降。

1.create方法由悲观锁(synchronized)保护: 并发高时,性能下降;

2.create方法底层有存储: 历史上生成过的copier非常多时,查询性能下降。

类卸载

资料2显示,BeanCopier增强的字节码缓存由一个两级map保存,第一级为WeakHashMap,第二级为HashMap,线程安全由synchronized保护。

第一级weakHashMap的key是classloader,因此类的卸载当classloader被回收时进行。

但类似的,如果是我们自己封装拷贝函数,也会面临字节码回收、metaspace占用的问题。

个人认为BeanCopier生成的字节码并不比自己手写的多很多,因此推荐使用BeanCopier。

可能的坑:

跨多个classloader的情况:https://stackoverflow.com/questions/20816197/use-cglib-beancopier-with-multiple-classloaders

BeanCopier无法判断两个不同classloader加载的同名类是不同的类。所以如果使用不同classloader加载同名类,需要特别考虑。

参考资料

https://www.cnblogs.com/winner-0715/p/10117282.html

https://www.jianshu.com/p/f8b892e08d26

https://www.cnblogs.com/mengdd/p/3594608.html

https://blog.csdn.net/xihuanyuye/article/details/89887913

https://ningyu1.github.io/blog/20190322/113-object-copy.html

spring拾遗

初始化顺序

如果有多个init方法,执行顺序:

  1. @PostContrust注解标注的方法;
  2. 继承InitialBean后自己实现的afterPropertiesSet方法;
  3. @Bean(initMethod="xxx")注解标注的方法;

销毁顺序

如果有多个销毁方法,执行顺序:(与上面类似)

  1. @PreDestory注解标注的方法;
  2. 继承DisposableBean后自己实现的destory方法;
  3. @Bean(destoryMethod="xxx")注解标注的方法;

spring异常风格

全都用非受检(runtimeException),代码变得简洁,不需要到处声明异常、或try catch;
副作用: 使用者需要自己意识到会抛各种BeanException

spring偏向锁

由于需要使用兼容java5,使用了synchronized
因此有偏向锁;
因此最好在main线程中初始化applicationContext,避免锁竞争,提高性能。

依赖注入

@Autowired 忽略静态字段;
接口回调注入: implement xxxAware接口后,会获得一个方法,可以从中得到、保存注入的对象。

强制的依赖: 构造器注入;
可选的依赖: setter注入; 字段注入; (时机、顺序不定、可循环)
配置类(声明类): 方法注入; @Bean

@Autowired的三个步骤:

1
2
3
1. 元信息解析: dependencyDescription;
2. 依赖查找: beanFactory
3. 依赖注入: 通过反射,注入到原字段。(所以很多都要求有setter)

提前注入

@Bean注解static方法时,会提前注入、加载。

依赖注入和依赖查找来源区别

依赖注入的来源默认会多4个,多4个spring自己默认注册的:

1
2
3
4
beanFactory
applicationContext
applicationEvenListener
ResourceLoader

这4个可以注入,但不能用getBean查找。
(只注册,不存到concurrentHashMap`)

大方向上,依赖注入4个来源:

  1. 托管的bean; definitionBean, 启动以后不能注册;
  2. 单例对象; 启动以后还能注册;
  3. 手动注册的resolvedDependency;
  4. 外部化配置;

prototype

spring不完全管prototype生命周期;
只管单例;
prototype销毁回调不执行,官方提到可以用BeanPostProcessor,但其实只能加初始化完成后的操作(此时大概率不需要销毁),所以更合理的方式是用一个单例对象管理所有prototype(类似于领域里的agg root),在单例销毁时(实现disposableBean接口),回调prototype的销毁;

单例和prototype均执行初始化回调。

scope request

@RequestScope
request scope对象: Ioc里是单例,但是返回给前端时是不同的。(快速销毁)
@SessionScope:
比request多一个锁。(避免同一个会话的并发)
同cookie时(同sessionid),返回前端的对象是相同的。(很长时间以后才销毁)
//tomcat默认session超时时间为30分钟,会序列化

jsp搜索范围:page-> request -> session -> servletContext

可以implements Scope自定义scope.

IocBean初始化

  1. beanDefinition加载;
  2. 合并父类元信息;
  3. 加载类; classLoader;
  4. 实例化; instantiation , 赋值: populate
  5. 返回前可以拦截替换代理对象;
  6. 初始化

赋值前可以通过postProcessProperties增加需要赋值的字段。虽然名字是post但其实是before.

Aware接口回调顺序

  1. beanNameAware
  2. beanClassLoaderAware
  3. beanfactoryAware
  4. environmentAware
  5. EmbeddedValueResolveAware
  6. ResourceLoaderAware
  7. ApplicationEventPublisherAware
  8. MessageSourceAware
  9. ApplicationcontextAware

4-9是applicationContext有的。

bean销毁

只是容器内销毁,并不意味着gc;

beanPostProcesser

可以有多个,spring用list存储,所以先添加的先执行(FIFO)。

生命周期末期

applicationContext关闭
gc
调用finalized方法

生命周期完整

注册
合并
实例化 前中后
赋值 前中后
aware回调
初始化 前中后
销毁 前中后
gc
finalize

工具类

ObjectUtils.nullsafeEquals
StringUtils.xxx
NamedThreadLocal

debug技巧

在底层打断点后,可以看方法调用栈。

spring事务回滚

受检异常:不回滚;
非受检:例如RuntimeException回滚。

类内调用: 无事务(因为不经过代理加强)
类外调用:有事务

事务中新开线程: 新线程不能复用原连接(查不到未提交的数据)
解决方案:使用事务同步管理器:

1
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {

mysql中立刻触发事务提交的语句

除了一些元数据操作,得注意新开一个事务也会触发原来的事务立即提交。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ALTER FUNCTION    
ALTER PROCEDURE
ALTER TABLE
BEGIN
CREATE DATABASE
CREATE FUNCTION
CREATE INDEX
CREATE PROCEDURE
CREATE TABLE
DROP DATABASE
DROP FUNCTION
DROP INDEX
DROP PROCEDURE
DROP TABLE
UNLOCK TABLES
LOAD MASTER DATA
LOCK TABLES
RENAME TABLE
TRUNCATE TABLE
SET AUTOCOMMIT=1
START TRANSACTION

BeanFactory初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
ResouceLoader加载配置信息
BeanDefintionReader解析配置信息,生成一个一个的BeanDefintion
BeanDefintion由BeanDefintionRegistry管理起来
BeanFactoryPostProcessor对配置信息进行加工(也就是处理配置的信息,一般通过PropertyPlaceholderConfigurer来实现)
实例化Bean
如果该Bean配置/实现了InstantiationAwareBean,则调用对应的方法
使用BeanWarpper来完成对象之间的属性配置(依赖)
如果该Bean配置/实现了Aware接口,则调用对应的方法
如果该Bean配置了BeanPostProcessor的before方法,则调用
如果该Bean配置了init-method或者实现InstantiationBean,则调用对应的方法
如果该Bean配置了BeanPostProcessor的after方法,则调用
将对象放入到HashMap中
最后如果配置了destroy或者DisposableBean的方法,则执行销毁操作

利用UTF-8编码的特性进行优化

引子

做性能优化的时候,画出火焰图(async-profiler)来看,发现耗时很多花在了utf-8的编码解码上了,所以需要思考一下如何优化这部分。
(采样30秒:./profiler.sh -d 30 -f profile.svg [pid])

What: 什么是UTF-8编码

UTF-8编码就是对于字节流(一串二进制)的解释,解释成字符串。
类似的还有: ASCII编码,就是把1B字节解释成128种字符。(范围是0-127,包括英文字母、数字)

UTF-8编码下,一个字符可以由1B~4B二进制组成。

UTF-8格式

合法的格式包括4种:

1
2
3
4
0xxxxxxx 				                0-127 (ASCII)
110xxxxx 10xxxxxx 128-2047
1110xxxx 10xxxxxx 10xxxxxx 2048-65535
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 65536-0x10ffff

它的首字节的前缀码会指出这个字符会占用多少字节。
合法的前缀码有4种:

1
2
3
4
0X:    占1B;
110X: 占2B;
1110: 占3B;
11110: 占4B;

显然还有一种10X不在其中。这是因为10X不属于合法的首字节的前缀码,只能在第2、3、4个字节中出现。如果我们随机选择utf-8二进制流中的一个字节来看的话,可能的情况有5种:

1
2
3
4
5
0X:     首字节,这个字符总长1B
10X: 非首字节.
110X: 首字节,这个字符总长2B
1110X: 首字节,这个字符总长3B
11110X: 首字节,这个字符总长4B

utf-8特性

  1. 兼容性: 0-127和ascii码是一样的,而且在其他字节不可能出现,因此和ascii完全兼容;
  2. 自同步性: 基于上面讨论的前缀码特性,从任意位置开始读,可以定位到合法位置开始解码。

优化方案

基于上述两点洞察,再考虑到我此次工作只需要解析字母数字部分,再直接分发二进制流即可。因此可以做两点优化:

  1. 读取时: 不解码,直接理解二进制的每个字节;
  2. 利用自同步性: 并发解析; 原先只能顺序解码,并发解析可以从任意位置开始;
  3. 输出时: 不编码,直接输出二进制;

结果

取消编码、解码后这部分耗时下降了12%;
并发处理则大幅提速,提速了一倍以上。

linux自连接

linux支持自连接,也就是同一个ip,同一个机器,自己起一个server、client互相连接。
例如使用命令:

1
nc localhost $port -p $port

对应TCP协议中的合法设定,同时打开:

误触可能

虽然这是linux的一个feature,但是我们平时遇到这种情况一般是因为bug,也就是误触。

一种可能:
进程1: listen端口A,然后挂掉,释放端口A;
进程2: 源端口A, 连接端口A。

触发bug条件

listen端口选择了net.ipv4.ip_local_port_range范围;

net.ipv4.ip_local_port_range范围里的端口是linux连接目标端口时,选择源端口的范围。可以通过修改/etc/sysctl.conf调整。(如果要调大connect并发度)

1
2
3
4
// 查看:
cat /proc/sys/net/ipv4/ip_local_port_range
// 生效:
sysctl -p /etc/sysctl.conf

因为connect使用的端口会从这个范围取,所以如果listen也用了这个范围,就很可能冲突,形成自连接。

并发度

内核3.2以前: 需保证源ip,源端口2元组不同,并发度更低; (随机选择端口)
内核3.2以后: 需保证源ip,源端口,目标ip,目标端口4元组不同即可,并发度更大; (顺序选择端口)

恶化bug条件

  1. 使用连接池,自连接无法释放;
  2. Linux内核>=3.10: 2.6是随机选择端口,3.10是顺序递增选择;

redis大key问题

  1. 为啥不能有大key;
  2. 有一些方法,避免大key;
  3. 有大key,安全删除大key;

what: 什么是大key问题

就是一个key的value特别大,比如一个hashmap中存了超多k,v;
或者一个列表key中存了超长列表,等等;
多大算大: hashmap中有100w的k,v => 1s延迟;
删除大Key的时间复杂度: O(N), N代表大key里的值数量,因为redis是单线程一个个删。
所以删大key也会卡qps。

开发规范

单key大小

Redis限制每个String类型value大小不超过512MB, 实际开发中,不要超过10KB,否则会对CPU和网卡造成极大负载。 hash、list、set、zset元素个数不要超过5000。

理论上限: 每个hashset里头元素数量< 2^32.

key的数量

官方评测: 单实例2.5亿
理论上限: 32位,2^32。约40亿

测试删除大key

可以用slowlog命令来查看删除耗时:

1
2
DEL big_key1
SLOWLOG GET 2

why: 为啥不能有大key

redis的基础假设是每个操作都很快,所以设计成单线程处理;
所以如果有大key,基础设计就不成立了,会阻塞;

问题:

  1. 数据倾斜,部分redis分片节点存储占用很高;
  2. 查询突然很慢,qps降低;

How: 如何避免大key

分治法,加一些key前缀\后置分解(如时间、哈希前缀、用户id后缀);

安全删除大key

  1. 首先要找到大key才能删除;
  2. 如何删除;

找到大key、删除大Key

当版本<4.0

1、导出rdb文件分析: bgsave, redis-rdb-tool;
2、命令: redis-cli --bigkeys,找出最大的key;
3、自己写脚本扫描;
4、单个key查看: debug object key: 查看某个key序列化后的长度,每次看1个key的信息,比较没效率。

删除大Key:

分解删除操作:
list: 逐步ltrim;
zset: 逐步zremrangebyscore;
hset: hscan出500个,然后hdel删除;
set: sscan扫描出500个,然后srem删除;
依次类推;

当版本>=4.0

寻找大key

命令: memory usage

删除大key: lazyfree机制

unlink命令:代替DEL命令;
会把对应的大key放到BIO_LAZY_FREE后台线程任务队列,然后在后台异步删除;

类似的异步删除命令:

1
2
flushdb async: 异步清空数据库
flushall async: 异步清空所有数据库

异步删除配置:

1
2
3
4
5
6
slave-lazy-flush: slave接受完rdb文件后,异步清空数据库;
lazyfree-lazy-eviction: 异步淘汰key;
lazyfree-lazy-expire: 异步key过期;
lazyfree-lazy-server-del: 异步内部删除key;生效于rename命令
## rename命令: RENAME mykey new_name
## 如果new_name已经存在,会先删除new_name,此时触发上述lazy机制

分布式事务

参考:
https://segmentfault.com/a/1190000016397619#item-1-1
https://www.cnblogs.com/duanxz/p/5226316.html

文中提到的主要是微信支付数据库层面的分布式事务优化。

优化前

架构上分为:
客户端;
CN: Coordinator,协调节点, 类似于对外的服务接口、代理,帮忙协调锁资源;
GTM: Global Transaction Manager, 类似于全局锁,全局事务管理器
DN: 具体数据节点;

逻辑很简单,就是CN每次向GTM申请锁,确保分布式事务的安全;
CN估计有多个,可能是react模式。
这种锁就像直接synchronized了全局,并发纯靠GTM单点纵向拓展。

优化后

锁下放到DN层。

分布式事务

分布式事务: 多个子系统一致成功、或一致失败回滚。

4种类型:

  1. 优先考虑避免分布式事务:可以将两个子系统的数据库表放在同一个从库下时:直接使用mysql事务,避免分布式事务;
  2. 无法放在同一个从库下时: 使用TCC;
  3. 特殊限制下: TCC+MQ;
  4. 只要求最终一致性时: 使用MQ异步处理,反复重试。

2PC和TCC的区别:
2PC: 数据库层,性能差(数据库锁);
TCC: 应用层,性能高,开发成本高(需要保证幂等性)。

TCC

TCC: Try-Cancel-Commit
开源实现: ByteTCC

1
2
3
4
5
6
7
8
9
10
11
1、Try:尝试执行业务。
完成所有业务检查(一致性)
预留必须业务资源(准隔离性)

2、Confirm:确认执行业务。
真正执行业务
不做任何业务检查
只使用Try阶段预留的业务资源

3、Cancel:取消执行业务
释放Try阶段预留的业务资源

其中Confirm和cancel接口需要是幂等的。

每个子事务实现TCC的几个幂等接口。

  1. 使用try锁定资源;
  2. 所有子事务: 写redo日志(持久化)\undo日志(回滚), 执行操作;
  3. 所有子事务: commit提交;
  4. 失败则cancel取消。

特例

最后一个子事务可以只实现TryCommit合并:

1
2
3
4
5
6
7
IF(代金券.Try) {
IF(现金支付.TryAndCommit) {
代金券.Commit
} {
代金券.Cancel
}
}

特殊限制下

超强一致性:两个从库之间转账。
特殊限制:

加钱: 可以try\commit\cancel;
减钱: 不能try,只能tryCommit(也就是一步到位,不能打日志反复重试(不让记录redo log(什么?你想记录用户的密码?)),不能cancel(有undo日志也无法回滚)),能失败。

方案

方案1: 从库A先+100,然后从库B-100,最后都提交。
方案2: 从库B先-100,然后从库A+100,最后都提交。

方案1, 需要平台先垫钱,肯定是不行的。
方案2, 如果-100的操作是无法try的(比如依赖银行系统),只能直接tryCommit,也就是只能位于末尾,否则就无法符合TCC的模式了。

方案3:
从库A+100: Try;
从库B-100: TryCommit;
从库A+100: Commit;

方案3可行,略麻烦。
方案3的简化:
从库B-100: TryCommit;
发布事件到MQ;
从库A订阅事件,反复重试+100。

最终一致性

将只需要最终一致性的子事务全部放在MQ中。
(类似发通知之类的事件)
发布事件到MQ,反复异步重试直到成功(削峰、错峰重试(随机探测))