-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathatom.xml
2072 lines (2039 loc) · 135 KB
/
atom.xml
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>https://zhangyaoo.github.io</id>
<title>will</title>
<updated>2022-04-22T03:19:10.986Z</updated>
<generator>https://github.com/jpmonette/feed</generator>
<link rel="alternate" href="https://zhangyaoo.github.io"/>
<link rel="self" href="https://zhangyaoo.github.io/atom.xml"/>
<subtitle>生死看淡,不服就干</subtitle>
<logo>https://zhangyaoo.github.io/images/avatar.png</logo>
<icon>https://zhangyaoo.github.io/favicon.ico</icon>
<rights>All rights reserved 2022, will</rights>
<entry>
<title type="html"><![CDATA[基于Netty的IM系统设计与实现]]></title>
<id>https://zhangyaoo.github.io/post/ji-yu-netty-ji-shi-tong-xun-xi-tong-she-ji-yu-shi-xian/</id>
<link href="https://zhangyaoo.github.io/post/ji-yu-netty-ji-shi-tong-xun-xi-tong-she-ji-yu-shi-xian/">
</link>
<updated>2021-12-01T04:02:35.000Z</updated>
<content type="html"><![CDATA[<h2 id="im系列文章">IM系列文章</h2>
<ol>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">IM系统业务认知,微博信息流业务与直播业务区别</a></li>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">IM架构设计与实现,架构设计难点</a></li>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">IM架构设计与实现,HTTPDNS和LSB负载均衡设计</a></li>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">IM架构设计与实现,长连接网关设计和实现</a></li>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">消息可靠性、即时性、顺序性如何保证,消息通知机制</a></li>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">自定义协议架构和设计,全双工和网络收发模型</a></li>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">未读数如何设计,系统未读数和群聊未读数如何设计</a></li>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">群聊设计与实现,性能问题读扩散or写扩散</a></li>
<li><a href="#1-im%E6%9E%B6%E6%9E%84%E5%9B%BE">IM系统高可用如何实现</a></li>
</ol>
<h2 id="一-功能">一、功能</h2>
<p>简介:🚀FastIM是基于Netty高可用分布式即时通讯系统,支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,支持集群部署的分布式架构。<br>
链接:https://github.com/zhangyaoo/fastim</p>
<h2 id="二-系统设计">二、系统设计</h2>
<h3 id="1-im架构图">1. IM架构图</h3>
<p>基于可扩展性高可用原则,把网关层、逻辑层、数据层分离,并且支持分布式部署<br>
<img src="https://zhangyaoo.github.io/post-images/architecture_new.png" alt="IM架构图" loading="lazy"></p>
<h3 id="2-架构设计">2. 架构设计</h3>
<h4 id="20-client设计">2.0 CLIENT设计:</h4>
<ol>
<li>client每个设备会在本地存每一个会话,保留有最新一条消息的顺序 ID</li>
<li>为了避免client宕机,也就是退出应用,保存在内存的消息ID丢失,会存到本地的文件中</li>
<li>client需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ack:N,以定时重发。</li>
<li>客户端本地生成一个递增序列号发送给服务器,用作保证发送顺序性。该序列号还用作ack队列收消息时候的移除。</li>
</ol>
<h5 id="21-客户端序列号设计">2.1 客户端序列号设计</h5>
<h5 id="方案一">方案一</h5>
<figure data-type="image" tabindex="1"><img src="https://zhangyaoo.github.io/post-images/sequenceId.png" alt="客户端序列号" loading="lazy"></figure>
<p>设计:</p>
<ul>
<li>
<p>数据传输中的大小尽量小用int,不用bigint,节省传输大小</p>
</li>
<li>
<p>只保证递增即可,在用户重新登录或者重连后可以进行日期重置,只保证单次</p>
</li>
<li>
<p>客户端发号器不需要像类似服务器端发号器那样集群部署,不需要考虑集群同步问题<br>
说明:上述生成器可以用18年[(2^29-1)/3600/24/365]左右,一秒内最多产生4个消息</p>
</li>
<li>
<p>优点:可以在断线重连和重装APP的情况下,18年之内是有序的</p>
</li>
<li>
<p>缺点:每秒只能发4个消息,限制太大,对于群发场景不合适</p>
</li>
<li>
<p>改进:使用long进行传输,年限扩展很久并且有序</p>
</li>
</ul>
<h5 id="方案二">方案二</h5>
<p>设计:</p>
<ul>
<li>
<p>每次重新建立链接后进行重置,将sequence_id(int表示)从0开始进行严格递增</p>
</li>
<li>
<p>客户端发送消息会带上唯一的递增sequence_id,同一条消息重复投递的sequence_id是一样的</p>
</li>
<li>
<p>后端存储每个用户的sequence_id,当sequence_id归0,用户的epoch年代加1存储入库,单聊场景下转发给接收者时候,接收者按照sequence_id和epoch来进行排序</p>
</li>
<li>
<p>优点:可以在断线重连和重装APP的情况下,接收者可以按照发送者发送时序来显示,并且对发送消息的速率没限制</p>
</li>
</ul>
<h4 id="21-lsb设计与优化">2.1 LSB设计与优化:</h4>
<h5 id="210-lsb设计">2.1.0 LSB设计</h5>
<ol>
<li>接入层的高可用、负载均衡、扩展性全部在这里面做</li>
<li>客户端通过LSB,来获取gate IP地址,通过IP直连,目的是
<ul>
<li>灵活的负载均衡策略 可根据最少连接数来分配IP</li>
<li>做灰度策略来分配IP</li>
<li>AppId业务隔离策略 不同业务连接不同的gate,防止相互影响</li>
<li>单聊和群聊的im接入层通道分开</li>
</ul>
</li>
</ol>
<h5 id="211-lsb优化">2.1.1 LSB优化</h5>
<p>问题背景:当某个实例重启后,该实例的连接断开后,客户端会发起重连,重连就大概率转移其他实例上,导致最近启动的实例连接数较少,最早启动的实例连接数较多<br>
解决方法:</p>
<ol>
<li>客户端会发起重连,跟服务器申请重连的新的服务器IP,系统提供合适的算法来平摊gate层的压力,防止雪崩效应。</li>
<li>gate层定时上报本机的元数据信息以及连接数信息,提供给LSB中心,LSB根据最少连接数负载均衡实现,来计算一个节点供连接。</li>
</ol>
<h4 id="22-gate设计">2.2 GATE设计:</h4>
<p>GATE层网关有以下特性:</p>
<ol>
<li>任何一个gate网关断掉,用户端检测到以后重新连接LSB服务获取另一个gate网关IP,拿到IP重新进行长连接通信。对整体服务可靠性基本没有影响。</li>
<li>gate可以无状态的横向部署,来扩展接入层的接入能力</li>
<li>根据协议分类将入口请求打到不同的网关上去,HTTP网关接收HTTP请求,TCP网关接收tcp长连接请求</li>
<li>长连接网关,提供各种监控功能,比如网关执行线程数、队列任务数、ByteBuf使用堆内存数、堆外内存数、消息上行和下行的数量以及时间</li>
</ol>
<h4 id="23-logic和路由sdk设计">2.3 LOGIC和路由SDK设计:</h4>
<ol>
<li>
<p>logic按照分布式微服务的拆分思想进行拆分,拆分为多个模块,集群部署:</p>
<ul>
<li>消息服务</li>
<li>红包服务</li>
<li>其他服务</li>
</ul>
</li>
<li>
<p>logic消息服务集成路由客户端SDK,SDK职责:</p>
<ul>
<li>负责和网关底层通信交互</li>
<li>负责网关服务寻址</li>
<li>负责存储uid和gate层机器ID关系(有状态:多级缓存避免和中间件多次交互。无状态:在业务初期可以不用存)</li>
<li>配合网关负责路由信息一致性保证
<ul>
<li>如果路由状态和channel通道不一致,比如有路由状态,没有channel通道(已关闭)那么,就会走离线消息流出,并且清除路由信息</li>
<li>动态重启gate,会及时清理路由信息</li>
</ul>
</li>
</ul>
</li>
<li>
<p>SDK和网关底层通信设计:<br>
<img src="https://zhangyaoo.github.io/post-images/gate_sdk.png" alt="交互设计图" loading="lazy"></p>
<ul>
<li>网关层到服务层,只需要单向传输发请求,网关层不需要关心调用的结果。而客户端想要的ack或者notify请求是由SDK发送数据到网关层,SDK也不需要关心调用的结果,最后网关层只转发数据,不做额外的逻辑处理</li>
<li>SDK和所有的网关进行长连接,当发送信息给客户端时,根据路由寻址信息,即可通过长连接推送信息</li>
</ul>
</li>
</ol>
<h3 id="3-协议设计">3. 协议设计</h3>
<h4 id="30-目标">3.0 目标</h4>
<ol>
<li>高性能:协议设计紧凑,保证数据包小,并且序列化性能好</li>
<li>可扩展性:针对后续业务发展,可以自由的自定义协议,无需较大改动协议结构</li>
</ol>
<h4 id="31-设计">3.1 设计</h4>
<p>IM协议采用二进制定长包头和变长包体来实现客户端和服务端的通信,并且采用谷歌protobuf序列化协议,设计如下:</p>
<figure data-type="image" tabindex="2"><img src="https://zhangyaoo.github.io/post-images/IM-protocol.png" alt="IM协议设计图" loading="lazy"></figure>
<p>各个字段如下解释:</p>
<ul>
<li>headData:头部标识,协议头标识,用作粘包半包处理。4个字节</li>
<li>version:客户端版本。4个字节</li>
<li>cmd:业务命令,比如心跳、推送、单聊、群聊。1个字节</li>
<li>msgType:消息通知类型 request response notify。1个字节</li>
<li>logId:调试性日志,追溯一个请求的全路径。4个字节</li>
<li>sequenceId:序列号,可以用作异步处理。4个字节</li>
<li>dataLength:数据体的长度。4个字节</li>
<li>data:数据</li>
</ul>
<h4 id="32-实践">3.2 实践</h4>
<ol>
<li>针对数据data,<strong>网关gate层不做反序列化,反序列化步骤在service做</strong>,避免重复序列化和反序列化导致的性能损失</li>
<li>网关层不做业务逻辑处理,只做消息转发和推送,减少网关层的复杂度</li>
</ol>
<h3 id="4-安全管理">4. 安全管理</h3>
<ol>
<li>为防止消息传输过程中不被截获、篡改、伪造,采用TLS传输层加密协议</li>
<li>私有化协议天然具备一定的防窃取和防篡改的能力,相对于使用JSON、XML、HTML等明文传输系统,被第三方截获后在内容破解上相对成本更高,因此安全性上会更好一些</li>
<li>消息存储安全性:针对账号密码的存储安全可以通过“高强度单向散列算法”和“加盐”机制来提升加密密码可逆性;IM消息采用“端到端加密”方式来提供更加安全的消息传输保护。</li>
<li>安全层协议设计。基于动态密钥,借鉴类似SSL,不需要用证书来管理。</li>
</ol>
<h3 id="5-消息流转设计">5. 消息流转设计</h3>
<p>一个正常的消息流转需要如图所示的流程:<br>
<img src="https://zhangyaoo.github.io/post-images/IM-pic1.png" alt="IM核心流程图" loading="lazy"></p>
<ol>
<li>客户端A发送请求包R</li>
<li>server将消息存储到DB</li>
<li>存储成功后返回确认ack</li>
<li>server push消息给客户端B</li>
<li>客户端B收到消息后返回确认ack</li>
<li>server收到ack后更新消息的状态或者删除消息</li>
</ol>
<p>需要考虑的是,一个健壮的系统需要考虑各种异常情况,如丢消息,重复消息,消息时序问题</p>
<h4 id="50-消息可靠性如何保证-不丢消息">5.0 消息可靠性如何保证 不丢消息</h4>
<ol>
<li>应用层ACK</li>
<li>客户端需要超时与重传</li>
<li>服务端需要超时与重传,具体做法就是增加ack队列和定时器Timer</li>
<li>业务侧兜底保证,客户端拉消息通过一个本地的旧的序列号来拉取服务器的最新消息</li>
<li>为了保证消息必达,在线客户端还增加一个定时器,定时向服务端拉取消息,避免服务端向客户端发送拉取通知的包丢失导致客户端未及时拉取数据。</li>
</ol>
<h4 id="51-消息重复性如何保证-不重复">5.1 消息重复性如何保证 不重复</h4>
<ol>
<li>超时与重传机制将导致接收的client收到重复的消息,具体做法就是一份消息使用同一个消息ID进行去重处理。</li>
</ol>
<h4 id="52-消息顺序性如何保证-不乱序">5.2 消息顺序性如何保证 不乱序</h4>
<h5 id="520-消息乱序影响的因素">5.2.0 消息乱序影响的因素</h5>
<ol>
<li>时钟不一致,分布式环境下每个机器的时间可能是不一致的</li>
<li>多发送方和多接收方,这种情况下,无法保先发的消息被先收到</li>
<li>网络传输和多线程,网络传输不稳定的话可能导致包在数据传输过程中有的慢有的快。多线程也可能是会导致时序不一致影响的因素</li>
</ol>
<p>以上,如果保持绝对的实现,那么只能是一个发送方,一个接收方,一个线程阻塞式通讯来实现。那么性能会降低。</p>
<h5 id="521-如何保证时序">5.2.1 如何保证时序</h5>
<ol>
<li>
<p>单聊:通过发送方的绝对时序seq,来作为接收方的展现时序seq。</p>
<ul>
<li>实现方式:可以通过时间戳或者本地序列号方式来实现</li>
<li>缺点:本地时间戳不准确或者本地序列号在意外情况下可能会清0,都会导致发送方的绝对时序不准确</li>
</ul>
</li>
<li>
<p>群聊:因为发送方多点发送时序不一致,所以通过服务器的单点做序列化,也就是通过ID递增发号器服务来生成seq,接收方通过seq来进行展现时序。</p>
<ul>
<li>实现方式:通过服务端统一生成唯一趋势递增消息ID来实现或者通过redis的递增incr来实现</li>
<li>缺点:redis的递增incr来实现,redis取号都是从主取的,会有性能瓶颈。ID递增发号器服务是集群部署,可能不同发号服务上的集群时间戳不同,可能会导致后到的消息seq还小。</li>
</ul>
</li>
<li>
<p>群聊时序的优化:按照上面的群聊处理,业务上按照道理只需要保证单个群的时序,不需要保证所有群的绝对时序,所以解决思路就是<strong>同一个群的消息落到同一个发号service</strong>上面,消息seq通过service本地生成即可。</p>
</li>
</ol>
<h5 id="522-客户端如何保证顺序">5.2.2 客户端如何保证顺序</h5>
<ol>
<li>
<p>为什么要保证顺序?</p>
<ul>
<li>消息即使按照顺序到达服务器端,也会可能出现:不同消息到达接收端后,可能会出现“先产生的消息后到”“后产生的消息先到”等问题。所以客户端需要进行兜底的流量整形机制</li>
</ul>
</li>
<li>
<p>如何保证顺序?</p>
<ul>
<li>接收方收到消息后进行判定,如果当前消息序号大于前一条消息的序号就将当前消息追加在会话里</li>
<li>否则继续往前查找倒数第二条、第三条等消息,一直查找到恰好小于当前推送消息的那条消息,然后插入在其后展示。</li>
</ul>
</li>
</ol>
<h3 id="6-消息通知设计">6 消息通知设计</h3>
<p><strong>整体消息推送和拉取的时序图如下:</strong><br>
<img src="https://zhangyaoo.github.io/post-images/msg-pull-push-model.png" alt="IM消息推拉模型" loading="lazy"></p>
<h4 id="60-消息拉取方式的选择">6.0 消息拉取方式的选择</h4>
<p>本项目是进行推拉结合来进行服务器端消息的推送和客户端的拉取,我们知道单pull和单push有以下缺点:</p>
<p>单pull:</p>
<ul>
<li>pull要考虑到消息的实时性,不知道消息何时送达</li>
<li>pull要考虑到哪些好友和群收到了消息,要循环每个群和好友拿到消息列表,读扩散</li>
</ul>
<p>单push:</p>
<ul>
<li>push实时性高,只要将消息推送给接收者就ok,但是会集中消耗服务器资源。并且再群聊非常多,聊天频率非常高的情况下,会增加客户端和服务端的网络交互次数</li>
</ul>
<p>推拉结合:</p>
<ul>
<li>推拉结合的方式能够分摊服务端的压力,能保证时效性,又能保证性能</li>
<li>具体做法就是有新消息时候,推送哪个好友或者哪个群有新消息,以及新消息的数量或者最新消息ID,客户端按需根据自身数据进行拉取</li>
</ul>
<h4 id="61-推拉隔离设计">6.1 推拉隔离设计</h4>
<ol>
<li>为什么做隔离
<ul>
<li>如果客户端一边正在拉取数据,一边有新的增量消息push过来</li>
</ul>
</li>
<li>如何做隔离
<ul>
<li>本地设置一个全局的状态,当客户端拉取完离线消息后设置状态为1(表示离线消息拉取完毕)。当客户端收到拉取实时消息,会启用一个轮询监听这个状态,状态为1后,再去向服务器拉取消息。</li>
<li>如果是push消息过来(不是主动拉取),那么会先将消息存储到本地的消息队列中,等待客户端上一次拉取数据完毕,然后将数据进行合并即可</li>
</ul>
</li>
</ol>
<h3 id="7-消息id生成设计">7 消息ID生成设计</h3>
<h5 id="70-设计">7.0 设计</h5>
<p>实际业务的情况【只做参考,实际可以根据公司业务线来调整】</p>
<ol>
<li>单机高峰并发量小于1W,预计未来5年单机高峰并发量小于10W</li>
<li>有2个机房,预计未来5年机房数量小于4个 每个机房机器数小于150台</li>
<li>目前只有单聊和群聊两个业务线,后续可以扩展为系统消息、聊天室、客服等业务线,最多8个业务线</li>
</ol>
<p>根据以上业务情况,来设计分布式ID:<br>
<img src="https://zhangyaoo.github.io/post-images/server-id.jpg" alt="IM服务端分布式ID设计" loading="lazy"></p>
<h5 id="71-优点">7.1 优点</h5>
<ul>
<li>不同机房不同机器不同业务线内生成的ID互不相同</li>
<li>每个机器的每毫秒内生成的ID不同</li>
<li>预留两位留作扩展位</li>
</ul>
<h5 id="72-缺点">7.2 缺点:</h5>
<p>当并发度不高的时候,时间跨毫秒的消息,区分不出来消息的先后顺序。因为时间跨毫秒的消息生成的ID后面的最后一位都是0,后续如果按照消息ID维度进行分库分表,会导致数据倾斜</p>
<h5 id="73-两种解决方案">7.3 两种解决方案:</h5>
<ol>
<li>方案一:去掉snowflake最后8位,然后对剩余的位进行取模</li>
<li>方案二:不同毫秒的计数,每次不是归0,而是归为随机数,相比方案一,比较简单实用</li>
</ol>
<h3 id="8-消息未读数设计">8 消息未读数设计</h3>
<h4 id="80-实现">8.0 实现</h4>
<ol>
<li>每发一个消息,消息接收者的会话未读数+1,并且接收者所有未读数+1</li>
<li>消息接收者返回消息接收确认ack后,消息未读数会-1</li>
<li>消息接收者的未读数+1,服务端就会推算有多少条未读数的通知</li>
</ol>
<h4 id="81-分布式锁保证总未读数和会话未读数一致">8.1 分布式锁保证总未读数和会话未读数一致</h4>
<ol>
<li>不一致原因:当总未读数增加,这个时候客户端来了请求将未知数置0,然后再增加会话未读数,那么会导致不一致</li>
<li>保证:为了保证总未读数和会话未读数原子性,需要用分布式锁来保证</li>
</ol>
<h4 id="82-群聊消息未读数难点和优化">8.2 群聊消息未读数难点和优化</h4>
<ol>
<li>
<p>难点</p>
<ul>
<li>一个群聊每秒几百的并发聊天,比如消息未读数,相当于每秒W级别的写入redis,即便redis做了集群数据分片+主从,但是写入还是单节点,会有写入瓶颈</li>
</ul>
</li>
<li>
<p>优化</p>
</li>
</ol>
<ul>
<li>群ID分组或者用户ID分组,批量写入,写入的两种方式
<ul>
<li>定时flush</li>
<li>满多少消息进行flush</li>
</ul>
</li>
</ul>
<h3 id="9-网关设计">9. 网关设计</h3>
<ul>
<li>接入层网关和应用层网关不同地方
<ul>
<li>接入层网关需要有接收通知包或者下行接收数据的端口,并且需要另外开启线程池。应用层网关不需要开端口,并且不需要开启线程池。</li>
<li>接入层网关需要保持长连接,接入层网关需要本地缓存channel映射关系。应用层网关无状态不需要保存。</li>
</ul>
</li>
</ul>
<h4 id="91-接入层网关设计">9.1 接入层网关设计</h4>
<h5 id="910-目标">9.1.0 目标:</h5>
<ol>
<li>网关的线程池实现1+8+4+1,减少线程切换</li>
<li>集中实现长连接管理和推送能力</li>
<li>与业务服务器解耦,集群部署缩容扩容以及重启升级不相互影响</li>
<li>长连接的监控与报警能力</li>
<li>客户端重连指令一键实现</li>
</ol>
<h5 id="911-技术点">9.1.1 技术点:</h5>
<ul>
<li>支持自定义协议以及序列化</li>
<li>支持websocket协议</li>
<li>通道连接自定义保活以及心跳检测</li>
<li>本地缓存channel</li>
<li>责任链</li>
<li>服务调用完全异步</li>
<li>泛化调用</li>
<li>转发通知包或者Push包</li>
<li>容错网关down机处理</li>
</ul>
<h5 id="912-设计方案">9.1.2 设计方案:</h5>
<p>一个Notify包的数据经网关的线程模型图:<br>
<img src="https://zhangyaoo.github.io/post-images/TCP-Gate-ThreadModel.png" alt="TCP网关线程模型" loading="lazy"></p>
<h4 id="92-应用层api网关设计">9.2 应用层API网关设计</h4>
<h5 id="920-目标">9.2.0 目标:</h5>
<ol>
<li>基于版本的自动发现以及灰度/扩容 ,不需要关注IP</li>
<li>网关的线程池实现1+8+1,减少线程切换</li>
<li>支持协议转换实现多个协议转换,基于SPI来实现</li>
<li>与业务服务器解耦,集群部署缩容扩容以及重启升级不相互影响</li>
<li>接口错误信息统计和RT时间的监控和报警能力</li>
<li>UI界面实现路由算法,服务接口版本管理,灰度策略管理以及接口和服务信息展示能力</li>
<li>基于OpenAPI提供接口级别的自动生成文档的功能</li>
</ol>
<h5 id="921-技术点">9.2.1 技术点:</h5>
<ul>
<li>Http2.0</li>
<li>channel连接池复用</li>
<li>Netty http 服务端编解码</li>
<li>责任链</li>
<li>服务调用完全异步</li>
<li>全链路超时机制</li>
<li>泛化调用</li>
</ul>
<h5 id="922-设计方案">9.2.2 设计方案:</h5>
<p>一个请求包的数据经网关的架构图:<br>
<img src="https://zhangyaoo.github.io/post-images/HTTP-gate.png" alt="网关的架构图" loading="lazy"></p>
<h3 id="10-高并发-高可用设计">10. 高并发、高可用设计</h3>
<h4 id="100-高并发设计">10.0 高并发设计</h4>
<h5 id="1000-架构优化">10.0.0 架构优化</h5>
<ul>
<li>水平扩展:各个模块无状态部署</li>
<li>线程模型:每个服务底层线程模型遵从Netty主从reactor模型</li>
<li>多层缓存:Gate层二级缓存,Redis一级缓存</li>
<li>长连接:客户端长连接保持,避免频繁创建连接消耗</li>
</ul>
<h5 id="1001-万人群聊优化">10.0.1 万人群聊优化</h5>
<ol>
<li>
<p>难点</p>
<ul>
<li>消息扇出大,比如每秒群聊有50条消息,群聊2000人,那么光一个群对系统并发就有10W的消息扇出</li>
</ul>
</li>
<li>
<p>优化</p>
<ul>
<li>批量ACK:每条群消息都ACK,会给服务器造成巨大的冲击,为了减少ACK请求量,参考TCP的Delay ACK机制,在接收方层面进行批量ACK。</li>
<li>群消息和成员批量加载以及懒加载:在真正进入一个群时才实时拉取群友的数据</li>
<li>群离线消息过多:群消息分页拉取,第二次拉取请求作为第一次拉取请求的ack</li>
<li>对于消息未读数场景,每个用户维护一个全局的未读数和每个会话的未读数,当群聊非常大时,未读资源变更的QPS非常大。这个时候应用层对未读数进行缓存,批量写+定时写来保证未读计数的写入性能</li>
<li>路由信息存入redis会有写入和读取的性能瓶颈,每条消息在发出的时候会查路由信息来发送对应的gate接入层,比如有10个群,每个群1W,那么1s100条消息,那么1000W的查询会打满redis,即使redis做了集群。优化的思路就是将集中的路由信息分散到msg层 JVM本地内存中,然后做Route可用,避免单点故障。</li>
<li>存储的优化,扩散写写入并发量巨大,另一方面也存在存储浪费,一般优化成扩散读的方式存储</li>
<li>消息路由到相同接入层机器进行合并请求减少网络包传输</li>
</ul>
</li>
</ol>
<h5 id="1002-代码优化">10.0.2 代码优化</h5>
<ul>
<li>本地会话信息由一个hashmap保持,导致锁机制严重,按照用户标识进行hash,讲会话信息存在多个map中,减少锁竞争</li>
<li>利用双buffer机制,避免未读计数写入阻塞</li>
</ul>
<h5 id="1003-推拉结合优化合并">10.0.3 推拉结合优化合并</h5>
<ol>
<li>背景:消息下发到群聊服务后,需要发送拉取通知给接收者,具体逻辑是群聊服务同步消息到路由层,路由层发送消息给接收者,接收者再来拉取消息。</li>
<li>问题:如果消息连续发送或者对同一个接收者连续发送消息频率过高,会有许多的通知消息发送给路由层,消息量过大,可能会导致logic线程堆积,请求路由层阻塞。</li>
<li>解决:发送者发送消息到逻辑层持久化后,将通知消息先存放一个队列中,相同的接收者接收消息通知消息后,更新相应的最新消息通知时间,然后轮训线程会轮训队列,将多个消息会合并为一个通知拉取发送至路由层,降低了客户端与服务端的网络消耗和服务器内部网络消耗。</li>
<li>好处:保证同一时刻,下发线程一轮只会向同一用户发送一个通知拉取,一轮的时间可以自行控制<br>
<img src="https://zhangyaoo.github.io/post-images/notifymerge.png" alt="客户端序列号" loading="lazy"></li>
</ol>
<h4 id="101-高可用设计">10.1 高可用设计</h4>
<h5 id="1010-心跳设计">10.1.0 心跳设计</h5>
<ol>
<li>服务端检测到某个客户端迟迟没有心跳过来可以主动关闭通道,让它下线,并且清除在线信息和路由信息;</li>
<li>客户端检测到某个服务端迟迟没有响应心跳也能重连获取一个新的连接。</li>
<li>智能心跳策略,比如正在发包的时候,不需要发送心跳。等待发包完毕后在开启心跳。并且自适应心跳策略调整。</li>
</ol>
<h5 id="1011-系统稳定性设计">10.1.1 系统稳定性设计</h5>
<ol>
<li>
<p>背景:高峰期系统压力大,偶发的网络波动或者机器过载,都有可能导致大量的系统失败。加上IM系统要求实时性,不能用异步处理实时发过来的消息。所以有了柔性保护机制防止雪崩</p>
</li>
<li>
<p>柔性保护机制开启判断指标,当每个指标不在平均范围内的时候就开启</p>
<ul>
<li>每条消息的ack时间 RT时间</li>
<li>同时在线人数以及同时发消息的人数</li>
<li>每台机器的负载CPU和内存和网络IO和磁盘IO以及GC参数</li>
</ul>
</li>
<li>
<p>当开启了柔性保护机制,那么会返回失败,用户端体验不友好,如何优化</p>
<ul>
<li>当开启了柔性保护机制,逻辑层hold住多余的请求,返回前端成功,不显示发送失败,后端异步重试,直至成功;</li>
<li>为了避免重试加剧系统过载,指数时间延迟重试</li>
</ul>
</li>
</ol>
<h5 id="1012-异常场景设计">10.1.2 异常场景设计</h5>
<ol>
<li>
<p>gate层重启升级或者意外down机有以下问题:</p>
<ul>
<li>客户端和gate意外丢失长连接,导致 客户端在发送消息的时候导致消息超时等待以及客户端重试等无意义操作</li>
<li>发送给客户端的消息,从Msg消息层转发给gate的消息丢失,导致消息超时等待以及重试。</li>
</ul>
</li>
<li>
<p>解决方案如下:</p>
<ul>
<li>重启升级时候,向客户端发送重新连接指令,让客户端重新请求LSB获取IP直连。</li>
<li>当gate层down机异常停止时候,增加hook钩子,向客户端发送重新连接指令。</li>
<li>额外增加hook,向Msg消息层发送请求清空路由消息和在线状态,并且清除redis的路由信息。</li>
</ul>
</li>
</ol>
<h5 id="1013-redis宕机高可用设计">10.1.3 Redis宕机高可用设计</h5>
<ol>
<li>
<p>Redis作用背景</p>
<ul>
<li>当用户链接上网关后,网关会将用户的userId和机器信息存入redis,用作这个user接收消息时候,消息的路由</li>
<li>消息服务在发消息给user时候,会查询Redis的路由信息,用来发送消息给哪个一个网关</li>
</ul>
</li>
<li>
<p>如果Redis宕机,会造成下面结果</p>
<ul>
<li>消息中转不过去,所有的用户可以发送消息,但是都接收不了消息</li>
<li>如果有在线机制,那么系统都认为是离线状态,会走手机消息通道推送</li>
</ul>
</li>
<li>
<p>Redis宕机兜底处理策略</p>
<ul>
<li>消息服务定时任务同步路由信息到本地缓存,如果redis挂了,从本地缓存拿消息</li>
<li>网关服务在收到用户侧的上线和下线后,会同步广播本地的路由信息给各个消息服务,消息服务接收后更新本地环境数据</li>
<li>网络交互次数多,以及消息服务多,可以用批量或者定时的方式同步广播路由消息给各个消息服务</li>
</ul>
</li>
</ol>
<h3 id="11-核心表结构设计">11. 核心表结构设计</h3>
<p>核心设计点</p>
<ol>
<li>群消息只存储一份,用户不需要为每个消息单独存一份。用户也无需去删除群消息。</li>
<li>对于在线的用户,收到群消息后,修改这个last_ack_msg_id。</li>
<li>对于离线用户,用户上线后,对比最新的消息ID和last_ack_msg_id,来进行拉取(参考Kafka的消费者模型)</li>
<li>对应单聊,需要记录消息的送达状态,以便在异常情况下来做重试处理</li>
</ol>
<h4 id="群用户消息表-t_group_user_msg">群用户消息表 t_group_user_msg</h4>
<table>
<thead>
<tr>
<th>字段</th>
<th>类型</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>id</td>
<td>int</td>
<td>自增ID</td>
</tr>
<tr>
<td>group_id</td>
<td>int</td>
<td>群ID</td>
</tr>
<tr>
<td>user_id</td>
<td>bigint</td>
<td>用户ID</td>
</tr>
<tr>
<td>last_ack_msg_id</td>
<td>bigint</td>
<td>最后一次ack的消息ID</td>
</tr>
<tr>
<td>user_device_type</td>
<td>tinyint</td>
<td>用户设备类型</td>
</tr>
<tr>
<td>is_deleted</td>
<td>tinyint</td>
<td>是否删除,根据这个字段后续可以做冷备归档</td>
</tr>
</tbody>
</table>
<h4 id="群消息表-t_group_msg">群消息表 t_group_msg</h4>
<table>
<thead>
<tr>
<th>字段</th>
<th>类型</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>id</td>
<td>int</td>
<td>自增ID</td>
</tr>
<tr>
<td>msg_id</td>
<td>bigint</td>
<td>消息ID</td>
</tr>
<tr>
<td>group_id</td>
<td>int</td>
<td>群ID</td>
</tr>
<tr>
<td>sender_id</td>
<td>bigint</td>
<td>发送方ID</td>
</tr>
<tr>
<td>msg_type</td>
<td>int</td>
<td>消息类型</td>
</tr>
<tr>
<td>msg_content</td>
<td>varchar</td>
<td>消息内容</td>
</tr>
</tbody>
</table>
<h3 id="12-核心业务流程">12 核心业务流程</h3>
<h4 id="120-用户a发消息给用户b-单聊">12.0 用户A发消息给用户B 【单聊】</h4>
<ul>
<li>A打包数据发送给服务端,服务端接收消息后,根据接收消息的sequence_id来进行客户端发送消息的去重,并且生成递增的消息ID,将发送的信息和ID打包一块入库,入库成功后返回ACK,ACK包带上服务端生成的消息ID</li>
<li>服务端检测接收用户B是否在线,在线直接推送给用户B</li>
<li>如果没有本地消息ID则存入,并且返回接入层ACK信息;如果有则拿本地sequence_id和推送过来的sequence_id大小对比,并且去重,进行展现时序进行排序展示,并且记录最新一条消息ID。最后返回接入层ack</li>
<li>服务端接收ACK后,将消息标为已送达</li>
<li>如果用户B不在线,首先将消息存入库中,然后直接通过手机通知来告知客户新消息到来</li>
<li>用户B上线后,拿本地最新的消息ID,去服务端拉取所有好友发送给B的消息,考虑到一次拉取所有消息数据量大,通过channel通道来进行分页拉取,将上一次拉取消息的最大的ID,作为请求参数,来请求最新一页的比ID大的数据。</li>
</ul>
<h4 id="121-用户a发消息给群g-群聊">12.1 用户A发消息给群G 【群聊】</h4>
<ul>
<li>登录,TCP连接,token校验,名词检查,sequence_id去重,生成递增的消息ID,群消息入库成功返回发送方ACK</li>
<li>查询群G所有的成员,然后去redis中央存储中找在线状态。离线和在线成员分不同的方式处理</li>
<li>在线成员:并行发送拉取通知,等待在线成员过来拉取,发送拉取通知包如丢失会有兜底机制</li>
<li>在线成员过来拉取,会带上这个群标识和上一次拉取群的最小消息ID,服务端会找比这个消息ID大的所有的数据返回给客户端,等待客户端ACK。一段时间没ack继续推送。如果重试几次后没有回ack,那么关闭连接和清除ack等待队列消息</li>
<li>客户端会更新本地的最新的消息ID,然后进行ack回包。服务端收到ack后会更新群成员的最新的消息ID</li>
<li>离线成员:发送手机通知栏通知。离线成员上线后,拿本地最新的消息ID,去服务端拉取群G发送给A的消息,通过channel通道来进行分页拉取,每一次请求,会将上一次拉取消息的最大的ID,作为请求参数来拉取消息,这里相当于第二次拉取请求包是作为第一次拉取的ack包。</li>
<li>分页的情况下,客户端在收到上一页请求的的数据后更新本地的最新的消息ID后,再请求下一页并且带上消息ID。上一页请求的的数据可以当作为ack来返回服务端,避免网络多次交互。服务端收到ack后会更新群成员的最新的消息ID</li>
</ul>
<h3 id="13-红包设计">13 红包设计</h3>
<h4 id="131-抢红包的大致核心逻辑">13.1 抢红包的大致核心逻辑</h4>
<ol>
<li>银行快捷支付,保证账户余额和发送红包逻辑的一致性</li>
<li>发送红包后,首先计算好红包的个数,个数确定好后,确定好每个红包的金额,存入存储层【这里可以是redis的List或者是队列】方便后续每个人来取</li>
<li>生成一个24小时的延迟任务,检测红包是否还有钱方便退回</li>
<li>每个红包的金额需要保证每个红包的的抢金额概率是一致的,算法需要考量</li>
<li>存入数据库表中后,服务器通过长连接,给群里notify红包消息,供群成员抢红包</li>
<li>群成员并发抢红包,在第二步中会将每个红包的金额放入一个队列或者其他存储中,群成员实际是来竞争去队列中的红包金额。兜底机制:如果redis挂了,可以重新生成红包信息到数据库中</li>
<li>取成功后,需要保证红包剩余金额、新插入的红包流水数据、队列中的红包数据以及群成员的余额账户金额一致性。</li>
<li>这里还需要保证一个用户只能领取一次,并且保持幂等</li>
</ol>
<h2 id="三-qa">三、Q&A</h2>
<ol start="0">
<li>
<p>Q:相比传统HTTP请求的业务系统,IM业务系统的有哪些不一样的设计难点?</p>
<ul>
<li>在线状态维护。相比于HTTP请求的业务系统,接入层有状态,必须维持心跳和会话状态,加大了系统设计复杂度。</li>
<li>请求通信模型不一样。相比于HTTP请求一个request等待一个response通信模型,IM系统则是一个数据包在全双工长连接通道双传输,客户端和服务端消息交互的信令数据包设计复杂。</li>
</ul>
</li>
<li>
<p>Q:对于单聊和群聊的实时性消息,是否需要MQ来作为通信的中间件来代替rpc?</p>
<p>A:MQ作为解耦可以有以下好处:</p>
<ul>
<li>易扩展,gate层到logic层无需路由,logic层多个有新的业务时候,只需要监听新的topic即可</li>
<li>解耦,gate层到logic层解耦,不会有依赖关系</li>
<li>节省端口资源,gate层无需再开启新的端口接收logic的请求,而且直接监听MQ消息即可</li>
</ul>
<p>但是缺点也有:</p>
<ul>
<li>网络通信多一次网络通信,增加RT的时间,消息实时性对于IM即使通信的场景是非常注重的一个点</li>
<li>MQ的稳定性,不管任何系统只要引入中间件都会有稳定性问题,需要考虑MQ不可用或者丢失数据的情况</li>
<li>需要考虑到运维的成本</li>
<li>当用消息中间代替路由层的时候,gate层需要广播消费消息,这个时候gate层会接收大部分的无效消息,因为这个消息的接收者channel不在本机维护的session中</li>
</ul>
<p>综上,是否考虑使用MQ需要架构师去考量,比如考虑业务是否允许、或者系统的流量、或者高可用设计等等影响因素。本项目基于使用成本、耦合成本和运维成本考虑,采用Netty作为底层自定义通信方案来实现,也能同样实现层级调用。</p>
</li>
<li>
<p>Q:为什么接入层用LSB返回的IP来做接入呢?</p>
<p>A:可以有以下好处:1、灵活的负载均衡策略 可根据最少连接数来分配IP;2、做灰度策略来分配IP;3、AppId业务隔离策略 不同业务连接不同的gate,防止相互影响</p>
</li>
<li>
<p>Q:为什么应用层心跳对连接进行健康检查?</p>
<p>A:因为TCP Keepalive状态无法反应应用层状态问题,如进程阻塞、死锁、TCP缓冲区满等情况;并且要注意心跳的频率,频率小则可能及时感知不到应用情况,频率大可能有一定的性能开销。</p>
</li>
<li>
<p>Q:MQ的使用场景?</p>
<p>A:IM消息是非常庞大的,比如说群聊相关业务、推送,对于一些业务上可以忍受的场景,尽量使用MQ来解耦和通信,来降低同步通讯的服务器压力。</p>
</li>
<li>
<p>Q:群消息存一份还是多份,读扩散还是写扩散?</p>
<p>A:存1份,读扩散。存多份下同一条消息存储了很多次,对磁盘和带宽造成了很大的浪费。可以在架构上和业务上进行优化,来实现读扩散。</p>
</li>
<li>
<p>Q:消息ID为什么是趋势递增就可以,严格递增的不行吗?</p>
<p>A:严格递增会有单点性能瓶颈,比如MySQL auto increments;redis性能好但是没有业务语义,比如缺少时间因素,还可能会有数据丢失的风险,并且集群环境下写入ID也属于单点,属于集中式生成服务。小型IM可以根据业务场景需求直接使用redis的incr命令来实现IM消息唯一ID。本项目采用snowflake算法实现唯一趋势递增ID,即可实现IM消息中,时序性,重复性以及查找功能。</p>
</li>
<li>
<p>Q:gate层为什么需要开两个端口?</p>
<p>A:gate会接收客户端的连接请求(被动),需要外网监听端口;entry会主动给logic发请求(主动);entry会接收服务端给它的通知请求(被动),需要内网监听端口。一个端口对内,一个端口对外。</p>
</li>
<li>
<p>Q:用户的路由信息,是维护在中央存储的redis中,还是维护在每个msg层内存中?</p>
<ul>
<li>维护在每个msg层内存中有状态:多级缓存避免和中间件多次交互, 并发高</li>
<li>维护在中央存储的redis中,msg层无状态,redis压力大,每次交互IO网络请求大<br>
业务初期为了减少复杂度,可以维护在Redis中</li>
</ul>
</li>
<li>
<p>Q:网关层和服务层以及msg层和网关层请求模型具体是怎样的?</p>
<ul>
<li>网关层到服务层,只需要单向传输发请求,网关层不需要关心调用的结果。而客户端想要的ack或者notify请求是由SDK发送数据到网关层,SDK也不需要关心调用的结果,最后网关层只转发数据,不做额外的逻辑处理。</li>
<li>SDK和所有的网关进行长连接,当发送信息给客户端时,根据路由寻址信息,即可通过长连接推送信息</li>
</ul>
</li>
<li>
<p>Q:本地写数据成功,一定代表对端应用侧接收读取消息了吗?<br>
A:本地TCP写操作成功,但数据可能还在本地写缓冲区中、网络链路设备中、对端读缓冲区中,并不代表对端应用读取到了数据。</p>
</li>
<li>
<p>Q:为什么用netty做来做http网关, 而不用tomcat?</p>
<ul>
<li>netty对象池,内存池,高性能线程模型</li>
<li>netty堆外内存管理,减少GC压力,jvm管理的只是一个很小的DirectByteBuffer对象引用</li>
<li>tomcat读取数据和写入数据都需要从内核态缓冲copy到用户态的JVM中,多1次或者2次的拷贝会有性能影响</li>
</ul>
</li>
<li>
<p>Q:为什么消息入库后,对于在线状态的用户,单聊直接推送,群聊通知客户端来拉取,而不是直接推送消息给客户端(推拉结合)?<br>
A:在保证消息实时性的前提下,对于单聊,直接推送。对于群聊,由于群聊人数多,推送的话一份群消息会对群内所有的用户都产生一份推送的消息,推送量巨大。解决办法是按需拉取,当群消息有新消息时候发送时候,服务端主动推送新的消息数量,然后客户端分页按需拉取数据。</p>
</li>
<li>
<p>Q:为什么除了单聊 群聊 推送 离线拉取等实时性业务,其他的业务都走http协议?<br>
A:IM协议简单最好,如果让其他的业务请求混进IM协议中,会让其IM变的更复杂,比如查找离线消息记录拉取走http通道避免tcp 通道压力过大,影响即时消息下发效率。在比如上传图片和大文件,可以利用HTTP的断点上传和分段上传特性</p>
</li>
<li>
<p>Q:机集群机器要考虑到哪些优化?<br>
A:网络宽带;最大文件句柄;每个tcp的内存占用;Linux系统内核tcp参数优化配置;网络IO模型;网络网络协议解析效率;心跳频率;会话数据一致性保证;服务集群动态扩容缩容</p>
</li>
</ol>
<h2 id="四-contact">四、Contact</h2>
<ul>
<li>网站:zhangyaoo.github.io</li>
<li>微信:will_zhangyao</li>
</ul>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[浅谈结构化思维]]></title>
<id>https://zhangyaoo.github.io/post/jie-gou-hua-si-wei/</id>
<link href="https://zhangyaoo.github.io/post/jie-gou-hua-si-wei/">
</link>
<updated>2021-07-23T03:54:14.000Z</updated>
<content type="html"><![CDATA[<h3 id="what">what:</h3>
<p>是一种以无序到有序的整理信息和构建结构化的思维方式,目的是减少认知复杂度,是的更加被容易理解和记忆,表达清晰</p>
<h3 id="why">why:</h3>
<p>0213645879 和 0123456789,这两串数字哪个更容易被人记住。<br>
当然是按照顺序排列的数字串比杂乱无序排列的要更容易被记住。<br>
因为人类更容易记住结构化的信息。</p>
<h3 id="how">how:</h3>
<p><img src="https://zhangyaoo.github.io/post-images/1627012568883.png" alt="" loading="lazy"><br>
塔尖就是我们的中心思想或主题。塔身就是构成中心思考或者主题的各个分论点。而塔基则是支撑各个分论点的要素或论据</p>
<p>1、综上而下的结构化思维<br>
纵向是自上而下的层次的关系,下一层是上一层的解释和构成,上一层是下一层的总结和概括<br>
比如,先给结论后给原因,先目的后方法,先抽象后具体,先整体后部分<br>
2、从左往右的顺序思维,在同一个组内必须是同一个逻辑范畴,按照顺序来组织</p>
<h4 id="具体的步骤">具体的步骤:</h4>
<p>1、确定问题产生背景<br>
2、确定核心目标<br>
3、拆解核心目标<br>
4、继续分解,直到能够把问题解释清楚,形成方法论</p>
<h3 id="example">example</h3>
<h4 id="1-采购清单">1、采购清单</h4>
<figure data-type="image" tabindex="1"><img src="https://zhangyaoo.github.io/post-images/1627012578248.png" alt="" loading="lazy"></figure>
<h4 id="2-带团队">2、带团队</h4>
<figure data-type="image" tabindex="2"><img src="https://zhangyaoo.github.io/post-images/1627012581164.png" alt="" loading="lazy"></figure>
<h4 id="3-事情划分方法">3、事情划分方法</h4>
<figure data-type="image" tabindex="3"><img src="https://zhangyaoo.github.io/post-images/1627012584884.png" alt="" loading="lazy"></figure>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[HikariDataSource核心源码分析]]></title>
<id>https://zhangyaoo.github.io/post/hikaridatasource-he-xin-yuan-ma-fen-xi/</id>
<link href="https://zhangyaoo.github.io/post/hikaridatasource-he-xin-yuan-ma-fen-xi/">
</link>
<updated>2021-02-24T03:31:24.000Z</updated>
<content type="html"><![CDATA[<h2 id="一-前言">一、前言</h2>
<p>上一篇文章讲了不合理的连接池代码导致的内存泄露事件,详见这篇文章<a href="https://zhangyaoo.github.io/post/ji-yi-ci-duo-shu-ju-yuan-lu-you-zao-cheng-de-shu-ju-ku-lian-jie-nei-cun-xie-lu/">记一次多数据源路由造成的数据库连接泄露排查过程</a>。其中粗略的分析了HikariDataSource连接池的代码,并没有仔细分析。本篇文章带读者们一起去分析一波源码,看完本篇文章后,你可以<br>
1、对锁和高并发有一定理解以及其在连接池中的运用<br>
2、了解HikariDataSource业界性能最高连接池的原因<br>
3、可以对连接池的原理有大致的了解,可以尝试自己实现一个连接池</p>
<h2 id="二-源码分析">二、源码分析</h2>
<h3 id="20-关键代码类介绍">2.0 关键代码类介绍</h3>
<ol>
<li>HikariDataSource对象:Hikari中的核心类为HikariDataSource,实现了DataSource的getConnetion接口</li>
<li>HikariPool对象:HikariDataSource中有两个HikariPool对象,一个是fastPathPool是在HikariPool有参构造函数中创建, 如果没有创建fastPathPool,那么就会在getConnection方法时创建pool对象。</li>
<li>ConcurrentBag对象:连接池的真正实现,实现了<strong>高性能高并发的无锁设计</strong>,主要的方法有borrow 借,requite 归还,add 新增,remove 去除。</li>
<li>PoolEntry对象:对数据库connection对象进行包装,增加额外的属性,包括最后一次访问时间,是否丢弃,当前状态等等。HikariDataSource源码底层里面都是操作这个对象。</li>
</ol>
<h3 id="21-初始化以下所有代码只列出关键实现">2.1 初始化(以下所有代码只列出关键实现)</h3>
<p>初始化工作包括HikariDataSource初始化,HikariPool初始化,connectionBag初始化,一些线程池的初始化,最小连接数的初始化,以下逐一分析</p>
<pre><code class="language-java">public HikariDataSource(HikariConfig configuration)
{
// 一个datasource有两个HikariPool成员变量,fastPathPool无参构造为null,用final修饰,pool有参构造,用volatile修饰
// 因为volatile修饰的对象,需要从主内存读取,而且需要写入主内存等操作,所以最好在使用上用有参构造来构造HikariDataSource
pool = fastPathPool = new HikariPool(this);
// 连接池配置锁定无法修改
this.seal();
}
// HikariPool初始化成员变量EntryCreator,创建PoolEntry的任务
private final PoolEntryCreator postFillPoolEntryCreator = new PoolEntryCreator("After adding ");
public HikariPool(final HikariConfig config)
{
// 初始化配置,根据配置生成datasource变量
super(config);
// 生成真正的连接池,后续的获取连接释放连接都是从这里面弄的
this.connectionBag = new ConcurrentBag<>(this);
// 测试数据库的连通性
checkFailFast();
// 增加连接的线程池
this.addConnectionExecutor = createThreadPoolExecutor(addConnectionQueue, poolName + " connection adder", threadFactory, new ThreadPoolExecutor.DiscardOldestPolicy());
// 关闭连接的线程池
this.closeConnectionExecutor = createThreadPoolExecutor(maxPoolSize, poolName + " connection closer", threadFactory, new ThreadPoolExecutor.CallerRunsPolicy());
// 初始化维持最小连接数任务
this.houseKeeperTask = houseKeepingExecutorService.scheduleWithFixedDelay(new HouseKeeper(), 100L, housekeepingPeriodMs, MILLISECONDS);
}
</code></pre>
<p>重点是维持最小连接数任务,如下:</p>
<pre><code class="language-java">private final class HouseKeeper implements Runnable{
public void run(){
// Detect retrograde time, allowing +128ms as per NTP spec.
if (plusMillis(now, 128) < plusMillis(previous, housekeepingPeriodMs)) {
previous = now;
// 标为丢弃的连接关闭
softEvictConnections();
return;
}
if (idleTimeout > 0L && config.getMinimumIdle() < config.getMaximumPoolSize()) {
// 获取当前连接池中已经不是使用中的连接集合
final List<PoolEntry> notInUse = connectionBag.values(STATE_NOT_IN_USE);
int toRemove = notInUse.size() - config.getMinimumIdle();
for (PoolEntry entry : notInUse) {
// 如果PoolEntry的最后一次访问的时间超过了idleTimeout并且将这个PoolEntry的状态变为不可借状态STATE_RESERVED
// STATE_RESERVED状态底层变更是CAS变更,用到的类是AtomicIntegerFieldUpdater,可以对指定类的指定 volatile int 字段进行原子更新
if (toRemove > 0 && elapsedMillis(entry.lastAccessed, now) > idleTimeout && connectionBag.reserve(entry)) {
// 关闭连接
closeConnection(entry, "(connection has passed idleTimeout)");
toRemove--;
}
}
}
// 填充最小连接到minimum。 在初始化时候就填充连接,异步填充
fillPool();
}
}
// 填充连接
// 当没有达到最大连接数之前 或者 空闲连接数小于最小连接数时候 就异步提交创建poolEntryCreator任务
private synchronized void fillPool(){
final int connectionsToAdd = Math.min(config.getMaximumPoolSize() - getTotalConnections(), config.getMinimumIdle() - getIdleConnections())
- addConnectionQueueReadOnlyView.size();
for (int i = 0; i < connectionsToAdd; i++) {
addConnectionExecutor.submit((i < connectionsToAdd - 1) ? poolEntryCreator : postFillPoolEntryCreator);
}
}
// 关闭连接
void closeConnection(final PoolEntry poolEntry, final String closureReason){
// 如果remove成功,将状态设置为STATE_REMOVED
if (connectionBag.remove(poolEntry)) {
final Connection connection = poolEntry.close();
// 异步关闭
closeConnectionExecutor.execute(() -> {
quietlyCloseConnection(connection, closureReason);
if (poolState == POOL_NORMAL) {
fillPool();
}
});
}
}
</code></pre>
<p>以上大致逻辑就是,将被标为丢弃的连接关闭,将空闲超时的连接进行关闭,然后进行进连接填充连接,填充连接的逻辑就是增加poolEntryCreator任务,poolEntryCreator逻辑在后面分析。</p>
<h3 id="22-获取连接">2.2 获取连接</h3>
<p>获取连接是主要的实现逻辑,首先看HikariDataSource对象getConnnection方法</p>
<pre><code class="language-java">// 双重锁实现
public Connection getConnection(){
HikariPool result = pool;
if (result == null) {
synchronized (this) {
result = pool;
if (result == null) {
pool = result = new HikariPool(this);
// 锁定配置,不能热更新配置
this.seal();
}
}
}
return result.getConnection();
}
</code></pre>
<p>result.getConnection()就是HikariPool getConnection方法,这里面大致核心逻辑就是加锁在超时时间内获取poolEntry的connectionBag.borrow方法,重点着重borrow方法实现。</p>
<p>在讲之前,先介绍以下connectionBag的几个重要的成员变量</p>
<ol>
<li>final CopyOnWriteArrayList<T> sharedList:当前所有缓存的poolEntry连接,都在这个list内,CopyOnWriteArrayList写时复制,在读多写少的场景下性能更高,一般情况下连接池中的poolEntry连接不会增加或者关闭,读场景多。</li>
<li>final ThreadLocal<List<Object>> threadList :当前线程缓存的本地poolEntry的list。朝生夕灭的线程,是无法有效利用本地线程缓存的,只有在线程池场景或者当前线程多次使用getConnetion获取connection方法进行增删改时候,才会有效的使用ThreadLocal。</li>
<li>final boolean weakThreadLocals:是否是弱引用,ThreadLocal可能会存在内存泄露的风险,当值为true时包装的poolEntry对象是弱引用,在内存不足时GC的时候会被回收,避免了出现内存泄露的问题。</li>
<li>final IBagStateListener listener:监听器,监听创建poolEntry的任务</li>
<li>final AtomicInteger waiters:当前正在等待获取连接的数量</li>
<li>final SynchronousQueue<T> handoffQueue:无存储元素的单个提供者和消费者通信队列,并且是公平模式。比如,是谁先来take操作,谁就会优先take成功,类似FIFO。</li>
<li>this.threadList = ThreadLocal.withInitial(() -> new FastList<>(IConcurrentBagEntry.class, 16)):默认情况下会ThreadLocal value默认使用fastList来存储poolEntry,fastList是Hikari自己写一个不需要范围检查的一个List,而且它的remove方法是从后往前遍历删除的(和arrayList相反),刚好符合下面倒叙遍历获取poolEntry的逻辑</li>
</ol>
<pre><code class="language-java">public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
{
// Try the thread-local list first
// 1、优先从本地线程缓存中获取poolEntry
final List<Object> list = threadList.get();
// 倒叙遍历,优先获取最近使用的poolEntry
for (int i = list.size() - 1; i >= 0; i--) {
final Object entry = list.remove(i);
// 开启弱引用就对value进行弱引用包装
final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
// CAS成功就返回poolEntry
if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}
// Otherwise, scan the shared list ... then poll the handoff queue
// 2、本地缓存没有,那么从所有缓存的poolEntry连接列表中获取
final int waiting = waiters.incrementAndGet();
try {
for (T bagEntry : sharedList) {
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
// If we may have stolen another waiter's connection, request another bag add.
// 如果等待的任务大于1,添加一个监听任务
if (waiting > 1) {
listener.addBagItem(waiting - 1);
}
return bagEntry;
}
}
// 如果sharedList都在使用状态中,添加一个监听任务
listener.addBagItem(waiting);
// 3、所有的连接正在被使用,超时等待其他poolEntry被归还通知handoffQueue
timeout = timeUnit.toNanos(timeout);
do {
final long start = currentTime();
// 从阻塞队列中获取bagEntry
final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
timeout -= elapsedNanos(start);
} while (timeout > 10_000);
return null;
}finally {
waiters.decrementAndGet();
}
}
</code></pre>
<p>以上具体实现步骤</p>
<ol>
<li>优先从本地线程缓存中获取poolEntry</li>
<li>本地缓存没有,那么从所有缓存的poolEntry连接列表中获取</li>
<li>所有的连接正在被使用,增加一个监听任务,这个任务就是异步创建poolEntry,以便给此次阻塞的线程提供poolEntry</li>
<li>超时等待其他poolEntry被归还或者新建后 通知handoffQueue,以便获取poolEntry</li>
</ol>
<p>可以总结出,Hikari连接池最大限度上减少多线程锁竞争,提升连接池的性能。</p>
<p>然后看一下异步创建poolEntry poolEntryCreator的实现:</p>
<pre><code class="language-java">private final class PoolEntryCreator implements Callable<Boolean> {
@Override
public Boolean call()
{
// 只有特定条件下才创建PoolEntry
while (poolState == POOL_NORMAL && shouldCreateAnotherConnection()) {
// 创建PoolEntry
final PoolEntry poolEntry = createPoolEntry();
if (poolEntry != null) {
// 在连接池中增加PoolEntry
connectionBag.add(poolEntry);
return Boolean.TRUE;
}
}
// Pool is suspended or shutdown or at max size
return Boolean.FALSE;
}
// 1、总的连接数小于最大连接数
// 2、当前连接池中的等待获取连接的线程大于0 或者 连接池中的空闲连接小于最小连接池数
// 满足所有上述条件后才创建poolEntry
private synchronized boolean shouldCreateAnotherConnection() {
return getTotalConnections() < config.getMaximumPoolSize() &&
(connectionBag.getWaitingThreadCount() > 0 || getIdleConnections() < config.getMinimumIdle());
}
}
// 真正创建poolEntry的实现
private PoolEntry createPoolEntry(){
try {
// 创建poolEntry
final PoolEntry poolEntry = newPoolEntry();
final long maxLifetime = config.getMaxLifetime();
if (maxLifetime > 0) {
// variance up to 2.5% of the maxlifetime
final long variance = maxLifetime > 10_000 ? ThreadLocalRandom.current().nextLong( maxLifetime / 40 ) : 0;
final long lifetime = maxLifetime - variance;
// 如果配置了maxlifetime,那么会给这一个连接增加一个延迟任务
// 延迟任务主要就是将这个连接标记为Evict不可用
poolEntry.setFutureEol(houseKeepingExecutorService.schedule(
() -> {
if (softEvictConnection(poolEntry, "(connection has passed maxLifetime)", false /* not owner */)) {
addBagItem(connectionBag.getWaitingThreadCount());
}
},