-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathatom.xml
executable file
·440 lines (218 loc) · 242 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
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Rason's Blog</title>
<link href="http://ideajava.com/atom.xml" rel="self"/>
<link href="http://ideajava.com/"/>
<updated>2024-04-26T08:25:44.832Z</updated>
<id>http://ideajava.com/</id>
<author>
<name>rason</name>
</author>
<generator uri="https://hexo.io/">Hexo</generator>
<entry>
<title>bookeeper-architecture</title>
<link href="http://ideajava.com/2024/04/26/bookeeper-architecture/"/>
<id>http://ideajava.com/2024/04/26/bookeeper-architecture/</id>
<published>2024-04-26T08:25:44.000Z</published>
<updated>2024-04-26T08:25:44.832Z</updated>
</entry>
<entry>
<title>容易被误会的 kafka auto commit</title>
<link href="http://ideajava.com/2020/03/27/kafka-autocommit/"/>
<id>http://ideajava.com/2020/03/27/kafka-autocommit/</id>
<published>2020-03-27T07:27:00.000Z</published>
<updated>2024-04-26T08:12:11.055Z</updated>
<content type="html"><![CDATA[<p>与 kafka auto commit 两个配置:</p><ul><li><code>enable.auto.commit</code>:是否开启自动提交</li><li><code>auto.commit.interval.ms</code>:自动提交时间间隔</li></ul><p>假设 <code>enable.auto.commit</code> 设置为 true,<code>auto.commit.interval.ms</code> 设置为 3000,试想一下会不会出现这样的问题:</p><p><code>poll</code> 方法返回了 500 条数据,需要 5 秒钟才能处理完,假设在第 4 秒的时候应用挂了,offset 是不是在第 3 秒的时候已经被自动提交了,从而导致第 4 秒之后的数据“丢失”了?</p><p>正确答案是:不会的!虽然 <code>auto.commit.interval.ms</code> 设置为 3000,但是检查时间间隔是否过了 3 秒是由 <code>poll</code> 方法去触发的,所以只要在记录还没处理完之前我们没有主动去调用 <code>poll</code> 方法,就算时间间隔到了,也不会去自动提交。</p><h3 id="自动提交是在哪里执行的"><a href="#自动提交是在哪里执行的" class="headerlink" title="自动提交是在哪里执行的"></a>自动提交是在哪里执行的</h3><p>kafka consumer offset 的提交是有 <code>org.apache.kafka.clients.consumer.internals.ConsumerCoordinator</code> 来完成的,真正执行提交的有两个方法:</p><ul><li>同步提交:<code>org.apache.kafka.clients.consumer.internals.ConsumerCoordinator#maybeAutoCommitOffsetsSync</code></li><li>异步提交:<code>org.apache.kafka.clients.consumer.internals.ConsumerCoordinator#maybeAutoCommitOffsetsAsync</code></li></ul><h4 id="同步提交"><a href="#同步提交" class="headerlink" title="同步提交"></a>同步提交</h4><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">private void maybeAutoCommitOffsetsSync(Timer timer) {</span><br><span class="line"> if (autoCommitEnabled) {</span><br><span class="line"> Map<TopicPartition, OffsetAndMetadata> allConsumedOffsets = subscriptions.allConsumed();</span><br><span class="line"> try {</span><br><span class="line"> log.debug("Sending synchronous auto-commit of offsets {}", allConsumedOffsets);</span><br><span class="line"> if (!commitOffsetsSync(allConsumedOffsets, timer))</span><br><span class="line"> log.debug("Auto-commit of offsets {} timed out before completion", allConsumedOffsets);</span><br><span class="line"> } catch (WakeupException | InterruptException e) {</span><br><span class="line"> log.debug("Auto-commit of offsets {} was interrupted before completion", allConsumedOffsets);</span><br><span class="line"> // rethrow wakeups since they are triggered by the user</span><br><span class="line"> throw e;</span><br><span class="line"> } catch (Exception e) {</span><br><span class="line"> // consistent with async auto-commit failures, we do not propagate the exception</span><br><span class="line"> log.warn("Synchronous auto-commit of offsets {} failed: {}", allConsumedOffsets, e.getMessage());</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>调用这个方法,当我们开启了自动提交,就会触发一个同步提交。那么哪里会调用这个方法?</p><ul><li>加入一个消费者组之前:<code>org.apache.kafka.clients.consumer.internals.ConsumerCoordinator#onJoinPrepare</code></li><li>关闭一个消费者之前:<code>org.apache.kafka.clients.consumer.internals.ConsumerCoordinator#close</code></li></ul><p>这两个触发点都跟我们要讨论的 <code>auto.commit.interval.ms</code> 问题无关,所以这里就不展开了。</p><h4 id="异步提交"><a href="#异步提交" class="headerlink" title="异步提交"></a>异步提交</h4><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">public void maybeAutoCommitOffsetsAsync(long now) {</span><br><span class="line"> if (autoCommitEnabled) {</span><br><span class="line"> nextAutoCommitTimer.update(now);</span><br><span class="line"> if (nextAutoCommitTimer.isExpired()) {</span><br><span class="line"> nextAutoCommitTimer.reset(autoCommitIntervalMs);</span><br><span class="line"> doAutoCommitOffsetsAsync();</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>当 nextAutoCommitTimer 到期了就会执行 <code>doAutoCommitOffsetsAsync()</code> 方法进行异步提交,这个到期时间间隔就是 <code>auto.commit.interval.ms</code> 设置的间隔,所以我们只要跟踪 <code>maybeAutoCommitOffsetsAsync</code> 方法的调用方就知道什么时候会检查是否已经到期,从而进行自动异步提交。</p><p>通过 IDEA 快捷键查看,也有两个地方调用:</p><ul><li>手动分配分区时:<code>org.apache.kafka.clients.consumer.KafkaConsumer#assign</code></li><li>拉取数据时:<code>org.apache.kafka.clients.consumer.KafkaConsumer#poll(java.time.Duration)</code></li></ul><p>手动分配分区时调用是确保消费者之前分配的老分区 offset 的提交,也和 <code>auto.commit.interval.ms</code> 无关。所以,无论同步提交还是异步提交,跟 <code>auto.commit.interval.ms</code> 有关的只剩下 <code>org.apache.kafka.clients.consumer.KafkaConsumer#poll(java.time.Duration)</code> 方法了,只有这个方法在正常情况下会被多次调用的。</p><p>这就验证了文章开头的问题,只要我们没有去调用 <code>poll</code> 方法,就算时间间隔到了,也无法触发自动提交。</p>]]></content>
<summary type="html"><p>与 kafka auto commit 两个配置:</p>
<ul>
<li><code>enable.auto.commit</code>:是否开启自动提交</li>
<li><code>auto.commit.interval.ms</code>:自动提交时间间隔</l</summary>
</entry>
<entry>
<title>TraceId 不见了</title>
<link href="http://ideajava.com/2020/03/02/where-is-my-trace-id/"/>
<id>http://ideajava.com/2020/03/02/where-is-my-trace-id/</id>
<published>2020-03-02T07:37:55.000Z</published>
<updated>2024-04-26T08:12:11.069Z</updated>
<content type="html"><![CDATA[<p>最近在帮忙排查一个问题,通过 Spring 集成 MQTT,在 接收 MQTT 消息的业务入口处打印出的日志没有 Sleuth 的 TraceId。但是,将接收到的消息丢到线程池去处理的时候,却又有了 TraceId。</p><p>代码大概逻辑如下:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">public class MqttMessageReceiver implements MqttCallbackExtended {</span><br><span class="line"> @Override</span><br><span class="line"> public void messageArrived(final String topic, final MqttMessage message) {</span><br><span class="line"></span><br><span class="line"> log.info("这行日志没有 TraceId ");</span><br><span class="line"></span><br><span class="line"> mqttAsyncTaskExecutor.execute(() -> {</span><br><span class="line"> this.messageHandle(topic, message.getPayload());</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> @Override</span><br><span class="line"> public void messageHandle(String key, Object value) {</span><br><span class="line">log.info("这行日志有 TraceId ");</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>而另外一个工程集成的 Sleuth 和 MQTT 是有 TraceId 的,所以下意识只做了一些简单的判断:</p><ol><li>难道是 Sleuth 的集成方式不对?梳理了一下 pom 依赖及其版本,没有解决问题。其实也能想到,因为部分是能打印出来的。</li><li>跟着了一下代码,发现代码中有进行 MDC 的相关操作,莫非没有的部分日志是把上下文清空了?跟踪了一下代码并没有这样的操作,只有自定义的 MDC 字段操作,即使删除了也没有解决问题。</li><li>难道是 Logback 配置不对导致没打印出来?检查了一下配置,有 TraceId 和没有 TraceId 的打印配置都是一样的,也排除了这个原因。</li></ol><p>冷静下来再想一下,既然上面的一顿操作都没有解决问题,那么问题的本质一定是:<strong>第一行日志线程上下文中有 TraceId,第二行日志线程上下文中没有 TraceId</strong>。</p><p>由于自己没有对 Sleuth 做过深入的研究,所以得出这个结论的时候还是对认知有点冲击的,因为:</p><ol><li>业界的链路追踪技术,一般都是在应用入口处就初始化了追踪上下文信息,所以对于第一行日志应该是有 TraceId 信息才对的。</li><li>反倒第二行日志没有才符合自己的认知的,因为新开了一个线程,追踪上下文应该没有传递过去才对。</li></ol><p>没辙,只好带着这两个问题粗略地去跟着一下 Sleuth 的源码。</p><h3 id="为什么入口没有追踪上下文信息"><a href="#为什么入口没有追踪上下文信息" class="headerlink" title="为什么入口没有追踪上下文信息"></a>为什么入口没有追踪上下文信息</h3><p>首先是第一个问题,第一行日志没有追踪上下文信息,难道是 MQTT 的集成入口没有初始化追踪上下文?有这样的推测是因为 MQTT 消息确实不是常规的业务入口,因为之前做的都是 Web 方面的开发较多,这是第一次在项目中使用 MQTT。</p><p>对于 Web 应该,一般会通过过滤器或者拦截器的技术方案初始化追踪上下文,这样到业务代码时就已经有了 TraceId 信息。所以<strong>推测通过 MQTT 消息的方式进来可能没有经过类似的过滤器或者拦截器</strong>。</p><p>作出了这样的推测,那就沿着这个方向是跟踪代码。对于 Sleuth 我并不熟悉,怎么快速的找到我需要的信息呢?</p><h4 id="全局搜索大法"><a href="#全局搜索大法" class="headerlink" title="全局搜索大法"></a>全局搜索大法</h4><p>此时只好祭出我常用的全局搜索大法了,IDEA 快捷键 <code>command + shift + F</code> 全文搜索 traceId,立马就搜到了重要的信息:</p><p><code>org.springframework.cloud.sleuth.log.Slf4jScopeDecorator</code></p><p>简单浏览了一下这个类,里面有一个 <code>TraceContext</code> ,这个命名是在是太赞了,一看就是我们要找的东西,保存追踪上下文的信息。</p><h4 id="引用搜索大法"><a href="#引用搜索大法" class="headerlink" title="引用搜索大法"></a>引用搜索大法</h4><p>知道了保存我们需要信息的类,那么通过快捷键 <code>option + F7</code> 查看哪些类引用了这个 TraceContext 就知道哪里会设置追踪上下文信息了。</p><p>如下图所示,发现引用的类还不少,大概看了下类名,初步判断红框里面这些类相关性可能会大一点:</p><p><img src="/image/TraceContext-ref.png" alt="TraceContext-ref"></p><p>简单浏览了这几个类的注释,发现 <code>TracingChannelInterceptor</code> 应该就是我需要找的类,注释如下:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">This starts and propagates Span.Kind#PRODUCER span for each message sent (via native headers. It also extracts or creates a Span.Kind#CONSUMER span for each message received. This span is injected onto each message so it becomes the parent when a handler later calls MessageHandler.handleMessage(Message), or a another processing library calls nextSpan(Message).</span><br><span class="line"></span><br><span class="line">This implementation uses ThreadLocalSpan to propagate context between callbacks. This is an alternative to ThreadStatePropagationChannelInterceptor which is less sensitive to message manipulation by other interceptors.</span><br></pre></td></tr></table></figure><p>大致的意思对每条发送的消息开始并传递一个 <code>Span.Kind#PRODUCER</code> 类型的 span。从这里大致可以推测,如果链路经过了这个 <code>TracingChannelInterceptor</code>,那么应该是会有 TraceId 的,难道是因为没有经过?</p><h4 id="断点调试大法"><a href="#断点调试大法" class="headerlink" title="断点调试大法"></a>断点调试大法</h4><p>要知道链路有没有经过一个类最简单的方法当然是断点调试了。于是我就在打印第一行日志的地方打了个断点,发现请求通过的链路截图如下:</p><p><img src="/image/has_no_trace_id.png" alt="has_no_trace_id"></p><p>链路比想象中的要短,通过简单的回调函数就到了我的业务类 <code>MqttMessageReceiver</code>,也就是文章开头的类,并没有经过 <code>TracingChannelInterceptor</code>,可能这就是没有 TraceId 的原因。</p><h4 id="对比验证大法"><a href="#对比验证大法" class="headerlink" title="对比验证大法"></a>对比验证大法</h4><p>既然另外一个工程集成的 Sleuth 和 MQTT 是有 TraceId 的,那么对比一下调用堆栈就好了,于是也在业务类接收 MQTT 消息的地方进行了调试:</p><p><img src="/image/has_trace_id.png" alt="has_trace_id"></p><p>发现调用堆栈多了很多类,图中我用红框框出来了,最后的 <code>MqttReceiver</code> 是这个工程接收 MQTT 消息的业务类。但是多出的堆栈调用中也没有 <code>TracingChannelInterceptor</code> 这个类,这时开始怀疑自己之前的推测是错误的。但是既然不一样的地方就是在红框这些地方,那么玄机一定就在这里面,所以我逐个逐个类点开分析了一遍,果然有所发现:</p><p><img src="/image/TracingChannelInterceptor.png" alt="TracingChannelInterceptor"></p><p>原来在 <code>AbstractMessageChannel</code> 类发送消息之前会经过 <code>TracingChannelInterceptor.png</code> 这个拦截器,到这里基本就验证了之前的推测,问题得到了实质性的突破。但是,为了两个工程经过的堆栈会有这么大的不同呢?</p><p>再次使用对比验证大法,通过走读两个工程接入 MQTT 的代码终于发现了问题的原因:<strong>两个工程集成 MQTT 的方式并不相同,出现问题的工程是直接 MQTT client,没有问题的工程是通过 Spring Integration 集成的</strong>。也就是说,只有 <code>Spring Integration</code> 集成的 MQTT 才会经过 <code>TracingChannelInterceptor</code>,才会有追踪上下文信息。这里也不得不感叹,<strong>Spring 封装得太好了,但对于使用者来说掩盖了太多东西了,遇到问题的时候排查起来还是得把封装的内容一层层剥开</strong> 。</p><h3 id="为什么丢到线程池反倒有追踪上下文信息"><a href="#为什么丢到线程池反倒有追踪上下文信息" class="headerlink" title="为什么丢到线程池反倒有追踪上下文信息"></a>为什么丢到线程池反倒有追踪上下文信息</h3><p>第一个问题已经解决了,但是为什么丢到线程池反倒有追踪上下文信息呢,推测应该是在某个地方传递过去了。有了上面的经验,这次直接就祭出断点调试大法:</p><p><img src="/image/TraceRunnable.png" alt="TraceRunnable"></p><p>一下子就看到了问题的本质,经过了一个 <code>TraceRunnable</code> 的类,看了一下这个类的内容,就是简单的代理了 <code>Runable</code> ,处理了追踪上下文信息,所以就是因为经过了这层代理才会有了追踪上下文信息。</p><p>但奇怪的是,我的代码中开启的线程池并没有显式地使用 <code>TraceRunnable</code> 这个类。再一次祭出调试大法,在 <code>mqttAsyncTaskExecutor.execute</code> 这行代码打上断点,然后 <code>Step Into</code>,发现 <code>ThreadPoolTaskExecutor</code> 被 <code>CGLIB</code> 增强了: </p><p><img src="/image/cglib_enhancer.png" alt="cglib_enhancer"></p><p>增强之后的代理类是 <code>LazyTraceThreadPoolTaskExecutor</code>,如下图所示:</p><p><img src="/image/LazyTraceThreadPoolTaskExecutor.png" alt="LazyTraceThreadPoolTaskExecutor"></p><p><code>LazyTraceThreadPoolTaskExecutor</code> 的核心作用就是将 <code>Runable</code> 包装成 <code>TraceRunnable</code>,那么现在的问题又变成了:我也没显式配置代理增加变成 <code>LazyTraceThreadPoolTaskExecutor</code> 啊,看来又是 Spring 封装搞的鬼。</p><p>继续查上面堆栈信息,又有了新的发现,如下图所示:</p><p><img src="/image/ExecutorBeanPostProcessor.png" alt="ExecutorBeanPostProcessor"></p><p>原来引入了 Sleuth 之后,会有一个 Executor 的后置处理器 <code>ExecutorBeanPostProcessor</code>,核心源码如下:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line">@Override</span><br><span class="line">public Object postProcessAfterInitialization(Object bean, String beanName)</span><br><span class="line">throws BeansException {</span><br><span class="line">if (bean instanceof LazyTraceThreadPoolTaskExecutor</span><br><span class="line">|| bean instanceof TraceableExecutorService</span><br><span class="line">|| bean instanceof LazyTraceAsyncTaskExecutor</span><br><span class="line">|| bean instanceof LazyTraceExecutor) {</span><br><span class="line">log.info("Bean is already instrumented " + beanName);</span><br><span class="line">return bean;</span><br><span class="line">}</span><br><span class="line">if (bean instanceof ThreadPoolTaskExecutor) {</span><br><span class="line">if (isProxyNeeded(beanName)) {</span><br><span class="line">return wrapThreadPoolTaskExecutor(bean);</span><br><span class="line">}</span><br><span class="line">else {</span><br><span class="line">log.info("Not instrumenting bean " + beanName);</span><br><span class="line">}</span><br><span class="line">}</span><br><span class="line">else if (bean instanceof ExecutorService) {</span><br><span class="line">if (isProxyNeeded(beanName)) {</span><br><span class="line">return wrapExecutorService(bean);</span><br><span class="line">}</span><br><span class="line">else {</span><br><span class="line">log.info("Not instrumenting bean " + beanName);</span><br><span class="line">}</span><br><span class="line">}</span><br><span class="line">else if (bean instanceof AsyncTaskExecutor) {</span><br><span class="line">if (isProxyNeeded(beanName)) {</span><br><span class="line">return wrapAsyncTaskExecutor(bean);</span><br><span class="line">}</span><br><span class="line">else {</span><br><span class="line">log.info("Not instrumenting bean " + beanName);</span><br><span class="line">}</span><br><span class="line">}</span><br><span class="line">else if (bean instanceof Executor) {</span><br><span class="line">return wrapExecutor(bean);</span><br><span class="line">}</span><br><span class="line">return bean;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>作用就是将各类 Executor 包装成含有插入追踪上下文的代理 Executor。到这里,两个问题终于得到了最终的解决。</p><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><p>回顾了一下整个过程,还是因为引入一项新的技术点(如 Sleuth 和 MQTT),Spring 封装了很多细节内容。</p><p>如:</p><p>只有通过 <code>Spring Integration</code> 集成的 MQTT 才有上下文信息,这也告诉我们 Spring 很多东西都是配套使用的,有官方的提供的集成方式还是优先使用官方的,少用原生集成方式。</p><p>又如:</p><p>引入了 Sleuth 之后,会自动通过 <code>ExecutorBeanPostProcessor</code> 对 <code>ThreadPoolTaskExecutor</code> 进行增强。</p><p>对于 Spring 这种高度的封装,我们还是需要一层层剥开探讨其细节才能更好解决问题,当排查的次数多了,基本的套路也是类似的。</p>]]></content>
<summary type="html"><p>最近在帮忙排查一个问题,通过 Spring 集成 MQTT,在 接收 MQTT 消息的业务入口处打印出的日志没有 Sleuth 的 TraceId。但是,将接收到的消息丢到线程池去处理的时候,却又有了 TraceId。</p>
<p>代码大概逻辑如下:</p>
<figur</summary>
</entry>
<entry>
<title>KafkaConsumer 基本知识</title>
<link href="http://ideajava.com/2020/02/11/kafkaconsumer/"/>
<id>http://ideajava.com/2020/02/11/kafkaconsumer/</id>
<published>2020-02-11T10:18:52.000Z</published>
<updated>2024-04-26T08:12:11.058Z</updated>
<content type="html"><![CDATA[<p>在开始阅读 Kafka Consumer 源码之前,先来温习一下 <code>KafkaConsumer</code> 的基本知识,本文主要翻译自官方文档。</p><h3 id="偏移量和消费位置"><a href="#偏移量和消费位置" class="headerlink" title="偏移量和消费位置"></a>偏移量和消费位置</h3><ul><li><strong>偏移量</strong>:Kafka 为分区中的每条记录维护着一个数字的偏移量,该偏移量充当着该记录在在分区中的唯一标识符,注意这是以分区为维度的。</li><li><strong>消费位置</strong>:表示的接下来将要读取的记录的偏移量。</li></ul><p>例如,一个消费者的位置为5,表示已经消费了从偏移量从0到4之间的记录,下一个将要消费的是偏移量为5的记录。</p><h3 id="消费者组和主题订阅"><a href="#消费者组和主题订阅" class="headerlink" title="消费者组和主题订阅"></a>消费者组和主题订阅</h3><p>Kafka 中的消费者组相当于一个线程池的概念,组内的每个消费者相当于线程池中的一个线程。这些消费者可以运行在同一台机器上,也可以运行在不同的机器上,从而提供了扩展性和容错性。所有共享同一个 <code>group.id</code> 的消费者实例属于同一个消费者组。</p><p>消费者组中的每个消费者都可以通过 <code> subscribe</code> 接口动态地订阅一系列的主题。一个主题中的一条消息只会被消费者组中的一个消费者消费,但是可以被不同消费者组中的多个消费者消费。这是因为一个主题的一个分区只能被一个消费者组的一个消费者消费,这句话可能有点绕。</p><p>例如:一个主题有 4 个分区,如果一个消费者组内有 4 个消费者,那么就是 1 个消费者消费 1 个分区;如果一个消费者组内只有 2 个消费者,那么就是 1 个消费者消费 2 个分区。</p><p>消费者组中的成员关系是被动态维护的:如果一个消费者挂了,该消费者被分配的分区将会被分配给消费组内其他的消费者。同样,如果有新的消费者加入该组,已有消费者的分区会被转移到新消费者,这叫做 <strong>组内再平衡</strong>。组内再平衡同样用于当新的分区被添加到一个被订阅的主题,或者当新创建的主题与订阅的正则表达式相匹配时。</p><p>从概念上讲,你可以认为一个消费者组是一个碰巧由多个进程组成的一个单个逻辑订阅者。作为一个多订阅者系统,Kafka 自然支持在不复制数据的情况下为给定主题拥有任意个消者组。</p><p>另外,当发生再平衡时,可以通过 <code>ConsumerRebalanceListener</code> 监听器来提醒消费者,完成应用程序级别的逻辑,比如状态清理,手动提交偏移量。</p><p>也可以使用 <code>assign(Collection)</code> 方法为消费者手动分配特定的分区,但是在这种情况下,动态分区分配和消费者组协作将被禁用。</p><h3 id="检测消费者故障"><a href="#检测消费者故障" class="headerlink" title="检测消费者故障"></a>检测消费者故障</h3><p>在订阅了一组主题后,调用 <code>poll(Duration)</code> 方法时,消费者就会自动加入组。<code>poll</code> 方法还被设计成消费者保活。只要你持续的调用 <code>poll</code>,该消费者将会保持在组中,持续的从被分配的分区中接收到消息。在底层,该消费者会发送定期心跳信号给服务器。如果消费者挂了或者在 <code>session.timeout.ms</code> 时间内没有发送心跳信号,则该消费者将会被认为挂了,并且它的分区将会被重新分配。</p><p>消费者还可能会 “livelock” 的情况,即持续发送心跳,但没有调用 <code>poll</code> 去获取数据。在这种情况下,为了避免分区被消费者持续占据,Kafka 用 <code>max.poll.interval.ms</code> 配置提供了一个 “livelock” 发现机制。基本上,如果你在配置的 <code>max.poll.interval.ms</code> 内没有调用 <code>poll</code> 方法,该消费者就会主动的离开组,以便其它的消费者可以接管它的分区。当这种情况发生时,你可能会看到一个偏移量提交故障(通过调用 <code>commitSync()</code> 方法抛出的 <code>CommitFailedException</code> 异常提示),这是一个安全机制,即保证只有组内活跃的成员才能提交偏移量。因此,为了保持在组里,你必须持续的调用 <code>poll</code> 方法。</p><p>消费者提供两个配置项来控制 <code>poll</code> 循环:</p><ul><li><p><strong>max.poll.interval.ms</strong>: 通过增加两个轮询的之间的时间间隔,可以留给消费者更多时间来处理 <code>poll(Duration)</code> 方法返回的记录。缺点是,增加此值可能会延迟组重新平衡,因为当超过这个时间还没有调用 <code>poll</code> 方法时消费者才会离开组,触发新的一轮再平衡。</p></li><li><p><strong>max.poll.records</strong>: 使用这个设置限定单次调用 <code>poll</code> 返回的记录总数。通过调整这个值,可以减少轮询间隔,这将减少组重新平衡的影响。</p></li></ul><p>对于消息处理时间变化不可预测的情况,这些配置项可能还不够。推荐将消息处理转移到其他线程的方式来处理这些情况,但是必须采取一些措施来保证提交的偏移量不会超过实际位置。通常,我们需要禁用自动提交,然后在线程完成对记录的处理之后(取决于所需的传递语义),通过手动提交已处理记录的偏移量。还要注意,需要通过调用 <code>pause</code> 方法暂停分区,以便在线程处理完之前返回的记录之前,不会从 <code>poll</code> 方法接收新记录。</p><h3 id="使用示例"><a href="#使用示例" class="headerlink" title="使用示例"></a>使用示例</h3><h4 id="自动提交偏移量"><a href="#自动提交偏移量" class="headerlink" title="自动提交偏移量"></a>自动提交偏移量</h4><p>下面是一个自动提交偏移量的 KafkaConsumer API 示例。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">Properties props = new Properties();</span><br><span class="line">props.put("bootstrap.servers", "localhost:9092");</span><br><span class="line">props.put("group.id", "test");</span><br><span class="line">props.put("enable.auto.commit", "true");</span><br><span class="line">props.put("auto.commit.interval.ms", "1000");</span><br><span class="line">props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");</span><br><span class="line">props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");</span><br><span class="line">KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);</span><br><span class="line">consumer.subscribe(Arrays.asList("foo", "bar"));</span><br><span class="line">while (true) {</span><br><span class="line"> ConsumerRecords<String, String> records = consumer.poll(100);</span><br><span class="line"> for (ConsumerRecord<String, String> record : records)</span><br><span class="line"> System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>通过 <code>bootstrap.servers</code> 指定一个或多个 broker 的列表来建立与集群的连接。此列表仅用于发现群集中的其余 broker,不必是群集中所有服务器的列表,设置多个可以防止某些 broker 挂了获取不到元数据。</p><p>设置 <code>enable.auto.commit=ture</code> 开启自动提交偏移量,自动提交频率由 <code>auto.commit.interval.ms</code> 控制。</p><h4 id="手动控制偏移量"><a href="#手动控制偏移量" class="headerlink" title="手动控制偏移量"></a>手动控制偏移量</h4><p>除了自动提交偏移量之外,用户也可以手动控制当记录真正被消费了才提交偏移量。当消息的消费与某些处理逻辑耦合时,这非常有用,因此在完成处理之前,不应将消息视为已消费。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">Properties props = new Properties();</span><br><span class="line">props.setProperty("bootstrap.servers", "localhost:9092");</span><br><span class="line">props.setProperty("group.id", "test");</span><br><span class="line">props.setProperty("enable.auto.commit", "false");</span><br><span class="line">props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");</span><br><span class="line">props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");</span><br><span class="line">KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);</span><br><span class="line">consumer.subscribe(Arrays.asList("foo", "bar"));</span><br><span class="line">final int minBatchSize = 200;</span><br><span class="line">List<ConsumerRecord<String, String>> buffer = new ArrayList<>();</span><br><span class="line">while (true) {</span><br><span class="line"> ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));</span><br><span class="line"> for (ConsumerRecord<String, String> record : records) {</span><br><span class="line"> buffer.add(record);</span><br><span class="line"> }</span><br><span class="line"> if (buffer.size() >= minBatchSize) {</span><br><span class="line"> insertIntoDb(buffer);</span><br><span class="line"> consumer.commitSync();</span><br><span class="line"> buffer.clear();</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>在这个例子中,当消费累积到 200 条之后批量插入数据库,然后才手工提交偏移量。如果像上个例子那样自动提交,还没插入数据库偏移量可能就已经被提交了,如果插入数据库失败,那么可能就丢失了这些数据。</p><p>不过,这个例子虽然用了手工提交,但是也有可能在插入数据库之后消费者挂了,偏移量没有被提交,那么消息可能会被重复消费。以这种方式使用,Kafka 提供了 <strong>“至少一次”</strong> 的交付保证,因为每个记录可能会交付一次,但在失败的情况下可能会重复。</p><p><strong>注意:使用自动提交偏移量也可以提供 “至少一次” 传递,但要求你必须在任何后续调用之前或在关闭消费者之前消费完调用 <code>poll(Duration)</code> 方法返回的所有数据。否则,提交的偏移量可能会超过消费的位置,从而导致丢失记录。使用手动提交偏移的好处是,你可以直接控制什么时候将记录视为 “已消费”。</strong></p><p>上面的示例使用 <code>commitSync</code> 会将所有接收到的记录标记为已提交。在某些情况下,你可能希望通过显式指定偏移量来更好地控制提交了哪些记录。在下面的示例中,我们在处理完每个分区中的记录后提交偏移量。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">try {</span><br><span class="line"> while(running) {</span><br><span class="line"> ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));</span><br><span class="line"> for (TopicPartition partition : records.partitions()) {</span><br><span class="line"> List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);</span><br><span class="line"> for (ConsumerRecord<String, String> record : partitionRecords) {</span><br><span class="line"> System.out.println(record.offset() + ": " + record.value());</span><br><span class="line"> }</span><br><span class="line"> long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();</span><br><span class="line"> consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastOffset + 1)));</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">} finally {</span><br><span class="line"> consumer.close();</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p><strong>注意:提交的偏移量应该始终是应用程序将读取的下一条消息的偏移量。</strong> 因此,在调用 <code>commitSync(offset)</code> 时,offset 的值应该是最后一条消息的偏移量加1。</p><h4 id="手动分配分区"><a href="#手动分配分区" class="headerlink" title="手动分配分区"></a>手动分配分区</h4><p>在前面的示例中,我们订阅了我们感兴趣的主题,同时让 Kafka 基于消费者组中的活跃消费者动态均衡地分配这些主题的分区。然而,某些时候你可能需要更精确的控制已被分配的特定分区。例如:</p><ul><li>如果进程维护与该分区相关联的某种本地状态(如本地磁盘上的键值存储),那么它应该只获取它在磁盘上维护的分区的记录。</li><li>如果该进程自身就是高可用,且失败时将会重启。在这种情况下,Kafka 没有必要检测失败和重新分配分区,因为消费进程将会在另一台机器上重启。</li></ul><p>要使用此模式,你只需调用 <code>assign(Collection)</code>,其中包含要使用的分区的完整列表,而不是使用 <code>subscribe</code> 订阅主题。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">String topic = "foo";</span><br><span class="line">TopicPartition partition0 = new TopicPartition(topic, 0);</span><br><span class="line">TopicPartition partition1 = new TopicPartition(topic, 1);</span><br><span class="line">consumer.assign(Arrays.asList(partition0, partition1));</span><br></pre></td></tr></table></figure><p>分配后,就像前面的示例中一样,在循环中调用 <code>poll</code> 来消费记录。消费者指定的组仍然用于提交偏移量,但是现在分区集只会在再一次调用 <code>assign</code> 时才会改变。手动分区分配不使用组协调,因此消费者挂了不会导致分配的分区重新平衡。每个消费都是独立的,即使它与其他消费者共享一个 groupId 。为了避免偏移量提交冲突,通常应该确保每个消费者实例的 groupId 是唯一的。</p><p>注意,不可能将手动分区分配(即使用 <code>assign</code> )与通过主题订阅(即使用 <code>subscribe</code> )动态分区分配混合使用。</p><h4 id="在Kafka之外存储偏移量"><a href="#在Kafka之外存储偏移量" class="headerlink" title="在Kafka之外存储偏移量"></a>在Kafka之外存储偏移量</h4><p>我们不是一定要使用 Kafka 内置的偏移量管理,也可以自行选择外部存储来管理偏移量。主要的用途是可以在同一个系统中保存偏移量和消费结果,即以原子方式保存结果和偏移量,并提供 “恰好一次” 语义。</p><p>比如: 消费的结果保存在一个关系型数据库,同时在数据库里保存偏移量,那么就可以在同一个事务中提交结果和偏移量。这样的话,要么事务提交成功同时保存消费的内容和更新偏移量,要么不存储结果也不更新偏移量。</p><p>管理自己的偏移量,你只需要执行下面的操作:</p><ul><li>配置 <code>enable.auto.commit=false</code></li><li>使用每个 <code>ConsumerRecord</code> 提供的偏移量来保存你的位置</li><li>重启时使用 <code>seek(TopicPartition,long)</code> 方法来恢复消费者的位置</li></ul><p>当分区分配也是手动完成时,这种方式的使用还算简单。如果分区分配是自动完成,需要特别注意处理分区分配改变的情况。可以通过在对 <code>subscribe(Collection, ConsumerRebalanceListener)</code> 和 <code>subscribe(Pattern, ConsumerRebalanceListener)</code> 的调用中提供一个 <code>ConsumerRebalanceListener</code> 实例来完成。</p><p>例如,当分区被释放时,消费者可以通过 <code>ConsumerRebalanceListener.onPartitionsRevoked(Collection)</code> 来提交这些分区的偏移量。当分区被分配给一个消费者,可以通过 <code>ConsumerRebalanceListener.onPartitionsAssigned(Collection)</code> 查询新分区的偏移量,进行消费位置的初始化。 </p><h4 id="控制消费者的位置"><a href="#控制消费者的位置" class="headerlink" title="控制消费者的位置"></a>控制消费者的位置</h4><p>在大多数用例中,消费者只是从开始到结束消费记录,定期提交它的位置(不论是自动还是手动)。然而,Kafka 允许消费者手动控制它的位置,随意在分区内向前或向后移动位置。这意味着消费者可以重复消费较旧的记录,或者跳过到最近的记录而不消费中间的记录。</p><p>有几种情况可能需要手动控制消费位置:</p><ul><li>一种情况是对时间敏感的记录处理,对于远远落后的记录,消费者其实是不希望一条条追赶上处理所有的记录,而是直接跳到最近的记录。</li><li>另一个用例是用于如前一节所述维护本地状态的系统。在这样的系统中,消费希望在启动时将它的位置初始化到被保存在本地存储的位置。同样,如果本地状态被销毁,该状态可能通过重新消费所有数据来重新创建状态。</li></ul><p>Kafka 可以通过 <code>seek(TopicPartition,offset)</code> 为指定分区位置。找到服务器维护的最早和最新偏移的特殊方法可以用 <code>seekToBegining(Collection)</code> 和 <code>seekToEnd(Collection)</code>。</p><h4 id="消费流量控制"><a href="#消费流量控制" class="headerlink" title="消费流量控制"></a>消费流量控制</h4><p>如果一个消费者从多个已分配的分区中获取数据,它将会尝试同时从所有分区中消费,从而效地为这些分区提供相同优先级以供消费。然而,在某些情况下消费者可能希望首先全速从其中一些分区中获取数据,只有当这些分区只有很少或者没有数据消费时才开始从其它的分区中获取数据。</p><p>Kafka 支持使用 <code>pause(Collection)</code> 和 <code>resume(Collection)</code> 动态控制消费流,以便在后续的 <code> poll(Duration)</code> 调用中分别暂停指定分区上的消费和恢复指定暂停分区上的消费。</p><h4 id="多线程处理"><a href="#多线程处理" class="headerlink" title="多线程处理"></a>多线程处理</h4><p>Kafka 的消费者不是线程安全的。需要用户自己确保多线程之间的同步访问。非同步的访问会抛出 <code>ConcurrentModificationException</code>。</p><p>多线程处理有两种常用方案:</p><h5 id="方案1"><a href="#方案1" class="headerlink" title="方案1"></a>方案1</h5><p>一个消费者一个线程</p><p>优点:</p><ul><li>实现简单,比较符合目前使用 Consumer API 的习惯</li><li>多个线程之间没有任何交互,省去了很多保障线程安全方面的开销</li><li>Kafka主题中的每个分区都能保证只被一个线程处理,容易实现分区内的消息消费顺序</li></ul><p>缺点:</p><ul><li>每个线程都维护自己的 <code>KafkaConsumer</code> 实例,必然会占用更多的系统资源,如内存、TCP连接等</li><li>能使用的线程数受限于Consumer订阅主题的总分区数</li><li>每个线程完整地执行消息获取和消息处理逻辑,一旦消息处理逻辑很重,消息处理速度很慢,很容易出现不必要的Rebalance,引发整个消费者组的消费停滞</li></ul><h5 id="方案2"><a href="#方案2" class="headerlink" title="方案2"></a>方案2</h5><p>单个消费者或多个消费者 + 多个任务处理线程,即将消息获取和消息处理分离:</p><ul><li>获取消息的线程可以是一个,也可以是多个,每个线程维护专属的KafkaConsumer实例</li><li>处理消息则由特定的线程池来做,从而实现消息获取和消息处理的真正解耦</li></ul><p>优点:</p><ul><li>把任务切分成消息获取和消息处理两部分,分别由不同的线程来处理</li><li>相对于方案1,方案2最大的优势是它的高伸缩性</li><li>可以独立地调节消息获取的线程数,以及消息处理的线程数,不必考虑两者之间是否相互影响</li></ul><p>缺点:</p><ul><li>实现难度大,因为要分别管理两组线程</li><li>消息获取和消息处理解耦,无法保证分区内的消费顺序</li><li>两组线程,使得整个消息消费链路被拉长,最终导致正确位移提交会变得异常困难,可能会出现消息的重复消费</li></ul>]]></content>
<summary type="html"><p>在开始阅读 Kafka Consumer 源码之前,先来温习一下 <code>KafkaConsumer</code> 的基本知识,本文主要翻译自官方文档。</p>
<h3 id="偏移量和消费位置"><a href="#偏移量和消费位置" class="headerlin</summary>
</entry>
<entry>
<title>Kafka producer 源码阅读(二)</title>
<link href="http://ideajava.com/2020/02/06/kafka-producer-2/"/>
<id>http://ideajava.com/2020/02/06/kafka-producer-2/</id>
<published>2020-02-06T14:04:14.000Z</published>
<updated>2024-04-26T08:12:11.057Z</updated>
<content type="html"><![CDATA[<p>这是阅读 Kafka producer 源码的第二篇文章,上篇文章最后提到了 Sender 线程主循环的 <code>sendProducerData</code> 方法并没有真正进行网络 IO 操作,真正的 IO 操作是在 <code>poll</code> 方法中完成的,这部分代码如果不了解 Java NIO 部分的知识是比较难理解的。</p><p>注意:Kafka 也封装了一个 Selector,全路径是 <code>org.apache.kafka.common.network.Selector</code>,而 Java NIO 的 Selector 全路径是 <code>java.nio.channels.Selector</code>。因为本文主要是分析 Kafka producer 源码,所以用 Selector 代表 Kafka 封装的,Java NIO 的用 nioSelector 来表示。</p><p>在开始分析 Kafka producer 源码之前,我们先来简单复习一下 nioSelector。</p><h3 id="nioSelector"><a href="#nioSelector" class="headerlink" title="nioSelector"></a>nioSelector</h3><p>先看一个 nioSelector 客户端的例子:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Selector nioSelector = Selector.open()</span><br><span class="line">SocketChannel socketChannel = SocketChannel.open();</span><br><span class="line">socketChannel.connect(new InetSocketAddress("localhost", 8089));</span><br><span class="line">SelectionKey key = socketChannel.register(nioSelector, SelectionKey.OP_CONNECT);</span><br></pre></td></tr></table></figure><p>主要是干了三件事情:</p><ol><li>打开一个多路复用选择器,也就是 nioSelector</li><li>打开一个 SocketChannel,建立连接</li><li>将 socketChannel 注册到 nioSelector,对该 socketChannel 感兴趣的事件是 SelectionKey.OP_CONNECT</li></ol><p>简单来说,nioSelector 就是用于监视我们所注册的所有 Channel,当 Channel 上发生注册时感兴趣的事情时,nioSelector 通知应用程序处理请求。一个 nioSelector 实例可以监视多个 Socket Channel,从而达到监视更多连接的作用。如下图所示:</p><p><img src="/image/nioSelector.png" alt="NIO Selector"></p><p>将 Channel 注册到 nioSelector ,会为每个 Channel 返回 <code>SelectionKey</code>。SelectionKey 是一个标识 Channel 的对象,它包含有关 Channel 状态的信息(例如准备接受请求),我们可以通过 SelectionKey 随时修改在该 Channel 上感兴趣的事件。</p><p>简单的复习之后,我们开始探讨 Kafka producer 网络方面的源码。</p><h3 id="发起建立连接"><a href="#发起建立连接" class="headerlink" title="发起建立连接"></a>发起建立连接</h3><p>要发送网络请求,首先得建立连接,所以我们先从建立连接的部分代码开始看,建立连接的方法是 <code>org.apache.kafka.clients.NetworkClient#initiateConnect</code>。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">// node 为 kafka broker 节点</span><br><span class="line">private void initiateConnect(Node node, long now) {</span><br><span class="line"> String nodeConnectionId = node.idString();</span><br><span class="line"> // 设置节点的连接状态为 ConnectionState.CONNECTING</span><br><span class="line"> connectionStates.connecting(nodeConnectionId, now, node.host(), clientDnsLookup);</span><br><span class="line"> // 节点的地址</span><br><span class="line"> InetAddress address = connectionStates.currentAddress(nodeConnectionId);</span><br><span class="line"> log.debug("Initiating connection to node {} using address {}", node, address);</span><br><span class="line"> // 调用 Selector 的连接方法</span><br><span class="line"> selector.connect(nodeConnectionId,</span><br><span class="line"> new InetSocketAddress(address, node.port()),</span><br><span class="line"> this.socketSendBuffer,</span><br><span class="line"> this.socketReceiveBuffer);</span><br><span class="line"></span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>上面这段代码比较简单,就是初始化连接状态、获取节点地址然后调用 <code>org.apache.kafka.common.network.Selector#connect</code> 方法建立连接。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">public void connect(String id, InetSocketAddress address, int sendBufferSize, int receiveBufferSize) throws IOException {</span><br><span class="line">// 确保到该 broker 节点的 channel 还没有注册</span><br><span class="line"> ensureNotRegistered(id);</span><br><span class="line"> // 打开一个 SocketChannel</span><br><span class="line"> SocketChannel socketChannel = SocketChannel.open();</span><br><span class="line"> SelectionKey key = null;</span><br><span class="line"> // 配置 socketChannel 相关属性,如 none block, keepalive 和收发 buffer 大小等</span><br><span class="line"> configureSocketChannel(socketChannel, sendBufferSize, receiveBufferSize);</span><br><span class="line"> // 建立连接</span><br><span class="line"> boolean connected = doConnect(socketChannel, address);</span><br><span class="line"> // 注册通过感兴趣的事件</span><br><span class="line"> key = registerChannel(id, socketChannel, SelectionKey.OP_CONNECT);</span><br><span class="line"> // 正常来说不会立马就成功建立连接,除非是本地客户端服务器</span><br><span class="line"> if (connected) {</span><br><span class="line"> // OP_CONNECT won't trigger for immediately connected channels</span><br><span class="line"> log.debug("Immediately connected to node {}", id);</span><br><span class="line"> immediatelyConnectedKeys.add(key);</span><br><span class="line"> key.interestOps(0);</span><br><span class="line"> }</span><br><span class="line"> </span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这段代码包含了一些信息量:</p><ol><li>ensureNotRegistered 是通过节点 id 查询是否已经有注册的 channel,可以看出一个 broker 只会建立一个 SocketChannel</li><li>doConnect 方法调用的是 <code>java.nio.channels.SocketChannel#connect</code>,我们配置了 non-blocking 模式并不会立马成功建立连接,除非是本地客户端服务端,所以大部分情况都应该返回 false,稍后必须通过调用 <code>java.nio.channels.SocketChannel#finishConnect</code> 方法完成连接操作</li><li>registerChannel 注册通过感兴趣的建立连接事件,用意就是后面调用 <code>org.apache.kafka.common.network.Selector#poll</code> 方法时监控 Channel 可连接时调用 <code>java.nio.channels.SocketChannel#finishConnect</code> 方法完成连接</li></ol><p>registerChannel 方法还有其他重要信息,我们展开看一下:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">protected SelectionKey registerChannel(String id, SocketChannel socketChannel, int interestedOps) throws IOException {</span><br><span class="line">// 将 socketChannel 注册到 nioSelector 上</span><br><span class="line"> SelectionKey key = socketChannel.register(nioSelector, interestedOps);</span><br><span class="line"> // 将 KafkaChannel 附加到注册返回的 SelectionKey 上</span><br><span class="line"> KafkaChannel channel = buildAndAttachKafkaChannel(socketChannel, id, key);</span><br><span class="line"> // 节点 ID 与 channel 的映射关系,也就是一个 broker 节点一个 channel</span><br><span class="line"> this.channels.put(id, channel);</span><br><span class="line"> if (idleExpiryManager != null)</span><br><span class="line"> // LRU 维护节点连接,闲置的连接可能会被关闭</span><br><span class="line"> idleExpiryManager.update(channel.id(), time.nanoseconds());</span><br><span class="line"> return key;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这里的重点是 <code>buildAndAttachKafkaChannel</code>,Kafka 不仅自己封装了一个 Selector ,还封装了一个 Channel,也就是 <code>KafkaChannel</code>。我们看下这个方法的内容:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">private KafkaChannel buildAndAttachKafkaChannel(SocketChannel socketChannel, String id, SelectionKey key) throws IOException {</span><br><span class="line">// 创建一个 KafkaChannel</span><br><span class="line"> KafkaChannel channel = channelBuilder.buildChannel(id, key, maxReceiveSize, memoryPool);</span><br><span class="line"> // 将 KafkaChannel 附加到注册返回的 SelectionKey 上</span><br><span class="line"> key.attach(channel);</span><br><span class="line"> return channel;</span><br><span class="line"> </span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>逻辑也比较简单,先创建一个 KafkaChannel 然后附加到 SelectionKey 上。 这里的 channelBuilder 根据配置会有不同的实现类:</p><ul><li><code>PlaintextChannelBuilder</code>: 明文</li><li><code>SslChannelBuilder</code>:SSL 加密 </li><li><code>SaslChannelBuilder</code>:简单认证</li></ul><p>我们以 PlaintextChannelBuilder 为例看一下:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">public KafkaChannel buildChannel(String id, SelectionKey key, int maxReceiveSize, MemoryPool memoryPool) throws KafkaException {</span><br><span class="line"> PlaintextTransportLayer transportLayer = new PlaintextTransportLayer(key);</span><br><span class="line"> Supplier<Authenticator> authenticatorCreator = () -> new PlaintextAuthenticator(configs, transportLayer, listenerName);</span><br><span class="line"> return new KafkaChannel(id, transportLayer, authenticatorCreator, maxReceiveSize,</span><br><span class="line"> memoryPool != null ? memoryPool : MemoryPool.NONE);</span><br><span class="line"> </span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">public class PlaintextTransportLayer implements TransportLayer {</span><br><span class="line"> private final SelectionKey key;</span><br><span class="line"> private final SocketChannel socketChannel;</span><br><span class="line"> private final Principal principal = KafkaPrincipal.ANONYMOUS;</span><br><span class="line"></span><br><span class="line"> public PlaintextTransportLayer(SelectionKey key) throws IOException {</span><br><span class="line"> this.key = key;</span><br><span class="line"> this.socketChannel = (SocketChannel) key.channel();</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>Kafka 包装了一个传输层,里面包含了之前注册返回的 <code>SelectionKey</code> 和注册的 <code>SocketChannel</code>,这对我们理解后面的逻辑很关键。</p><p>到这里,发起建立连接请求的代码分析的差不多了,但是真正调用 <code>java.nio.channels.SocketChannel#finishConnect</code> 完成连接的建立部分代码还没分析。现在,先让我用一张图来梳理一下目前涉及到的几个核心类的关系:</p><p><img src="/image/kafka-producer-network.png" alt="NIO Selector"></p><h3 id="完成连接的建立"><a href="#完成连接的建立" class="headerlink" title="完成连接的建立"></a>完成连接的建立</h3><p>之前已经说过,真正完成连接的建立是在 Sender 线程主循环调用 <code>org.apache.kafka.clients.NetworkClient#poll</code> 方法。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line">public List<ClientResponse> poll(long timeout, long now) {</span><br><span class="line"> ensureActive();</span><br><span class="line"></span><br><span class="line"> if (!abortedSends.isEmpty()) {</span><br><span class="line"> // 如果由于不支持的版本异常或断开连接而中止发送,需要立即处理</span><br><span class="line"> List<ClientResponse> responses = new ArrayList<>();</span><br><span class="line"> handleAbortedSends(responses);</span><br><span class="line"> completeResponses(responses);</span><br><span class="line"> return responses;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 看是否需要先更新集群的元数据</span><br><span class="line"> long metadataTimeout = metadataUpdater.maybeUpdate(now);</span><br><span class="line"> try {</span><br><span class="line"> // 这是我们要核心关注的,调用 org.apache.kafka.common.network.Selector#poll 方法</span><br><span class="line"> this.selector.poll(Utils.min(timeout, metadataTimeout, defaultRequestTimeoutMs));</span><br><span class="line"> } catch (IOException e) {</span><br><span class="line"> log.error("Unexpected error during I/O", e);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 处理完成的动作</span><br><span class="line"> long updatedNow = this.time.milliseconds();</span><br><span class="line"> List<ClientResponse> responses = new ArrayList<>();</span><br><span class="line"> handleCompletedSends(responses, updatedNow);</span><br><span class="line"> handleCompletedReceives(responses, updatedNow);</span><br><span class="line"> handleDisconnections(responses, updatedNow);</span><br><span class="line"> handleConnections();</span><br><span class="line"> handleInitiateApiVersionRequests(updatedNow);</span><br><span class="line"> handleTimedOutRequests(responses, updatedNow);</span><br><span class="line"> completeResponses(responses);</span><br><span class="line"></span><br><span class="line"> return responses;</span><br><span class="line"> }</span><br></pre></td></tr></table></figure><p>这段代码的思路很清晰,就是先调用 <code>org.apache.kafka.common.network.Selector#poll</code> 方法进行 IO 读写、连接建立等,然后处理完成的动作。我们来看一下删减版的 <code>org.apache.kafka.common.network.Selector#poll</code> 代码,主要关注是怎么完成连接的建立的。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">public void poll(long timeout) throws IOException {</span><br><span class="line">int numReadyKeys = select(timeout);</span><br><span class="line">if (numReadyKeys > 0 || !immediatelyConnectedKeys.isEmpty() || dataInBuffers) {</span><br><span class="line"> Set<SelectionKey> readyKeys = this.nioSelector.selectedKeys();</span><br><span class="line"> // 处理有 IO 事件准备好的 SelectionKey</span><br><span class="line"> pollSelectionKeys(readyKeys, false, endSelect);</span><br><span class="line"> readyKeys.clear();</span><br><span class="line"> } else {</span><br><span class="line"> madeReadProgressLastPoll = true; //no work is also "progress"</span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">// 检查 Channel 是否有数据,阻塞到给定的超时时间</span><br><span class="line">private int select(long timeoutMs) throws IOException {</span><br><span class="line"> if (timeoutMs == 0L)</span><br><span class="line"> return this.nioSelector.selectNow();</span><br><span class="line"> else</span><br><span class="line"> return this.nioSelector.select(timeoutMs);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这里就是 Java NIO 的知识了,通过 nioSelector 检查注册的通过是否有数据可以进行操作,获取相应的 SelectionKey 集合然后进行处理。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br></pre></td><td class="code"><pre><span class="line">void pollSelectionKeys(Set<SelectionKey> selectionKeys,</span><br><span class="line"> boolean isImmediatelyConnected,</span><br><span class="line"> long currentTimeNanos) {</span><br><span class="line"> // 循序 SelectionKey 进行处理 </span><br><span class="line">for (SelectionKey key : determineHandlingOrder(selectionKeys)) {</span><br><span class="line">KafkaChannel channel = channel(key);</span><br><span class="line"></span><br><span class="line">// 处理已经完成握手的所有连接</span><br><span class="line"> if (isImmediatelyConnected || key.isConnectable()) {</span><br><span class="line"> // 这里就是我们要找的代码了,正式完成连接的建立</span><br><span class="line"> if (channel.finishConnect()) {</span><br><span class="line"> // 将节点 ID 添加到已经完成连接的集合 </span><br><span class="line"> this.connected.add(channel.id());</span><br><span class="line"> } else {</span><br><span class="line"> continue;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 如果 Channel 已准备就绪,并且有字节可从 Socket 或 Buffer 读取,并且之前没有已暂存或正在进行的接收,则从 Channel 读取</span><br><span class="line"> if (channel.ready() && (key.isReadable() || channel.hasBytesBuffered()) && !hasStagedReceive(channel)</span><br><span class="line"> && !explicitlyMutedChannels.contains(channel)) {</span><br><span class="line"> NetworkReceive networkReceive;</span><br><span class="line"> while ((networkReceive = channel.read()) != null) {</span><br><span class="line"> madeReadProgressLastPoll = true;</span><br><span class="line"> addToStagedReceives(channel, networkReceive);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 如果 Channel 已准备就绪,并且可以写了,则向 Channel 写入</span><br><span class="line"> if (channel.ready() && key.isWritable() && !channel.maybeBeginClientReauthentication(</span><br><span class="line"> () -> channelStartTimeNanos != 0 ? channelStartTimeNanos : currentTimeNanos)) {</span><br><span class="line"> Send send = channel.write();</span><br><span class="line"> ...</span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">// 这里就是之前将 KafkaChannel 附加到 SelectionKey 的作用了,会根据 SelectionKey 获取出 KafkaChannel</span><br><span class="line">private KafkaChannel channel(SelectionKey key) {</span><br><span class="line"> return (KafkaChannel) key.attachment();</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>以上,算是标准的 Java NIO 使用姿势了,有啥事件进行相应的处理。对于我们前面发起的建立连接时注册的 <code>SelectionKey.OP_CONNECT</code> 事件,轮询到 <code>key.isConnectable()</code> ,这里就调用 <code>channel.finishConnect()</code> 完成连接的建立。</p><p>相信大家现在应该可以举一反三,对于读写操作不用继续分析。</p><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><p>Kafka producer 网络部分的源码核心就是 Java NIO ,当我们把 NIO 理解透了,看起来应该就比较简单了。</p>]]></content>
<summary type="html"><p>这是阅读 Kafka producer 源码的第二篇文章,上篇文章最后提到了 Sender 线程主循环的 <code>sendProducerData</code> 方法并没有真正进行网络 IO 操作,真正的 IO 操作是在 <code>poll</code> 方法中完成的</summary>
</entry>
<entry>
<title>Kafka producer 源码阅读(一)</title>
<link href="http://ideajava.com/2020/02/04/kafka-producer-1/"/>
<id>http://ideajava.com/2020/02/04/kafka-producer-1/</id>
<published>2020-02-04T10:01:51.000Z</published>
<updated>2024-04-26T08:12:11.056Z</updated>
<content type="html"><![CDATA[<p>最近花了几天时间阅读了 Kafka producer 的源码,内部的细节问题还是挺多的,还没有一行行细扣,只是看了下主要的脉络,尝试梳理一下其中的逻辑。</p><p>主要流程涉及以下几个核心类:</p><ul><li>KafkaProducer</li><li>RecordAccumulator</li><li>Sender</li><li>NetworkClient</li><li>org.apache.kafka.common.network.Selector</li><li>KafkaChannel</li></ul><p>核心链路可以简单概括为四点:</p><ol><li>通过 <code>KafkaProducer</code> 发送的消息会先到 <code>RecordAccumulator</code>,RecordAccumulator 是一个消息累积器,我们知道 Kafka 消息是可以批量发送的</li><li><code>RecordAccumulator</code> 中的消息批次满了或者新建了一个批次会唤醒 <code>Sender</code> 线程对消息进行发送,当然 <code>linger.ms</code> 时间到了也会对消息进行发送</li><li><code>Sender</code> 线程通过 <code>NetworkClient</code> 构造一个发送请求,然后调用 <code>org.apache.kafka.common.network.Selector</code> 的 <code>send</code> 方法注册感兴趣的写事件</li><li>随后 <code>NetworkClient</code> 的 <code>poll</code> 方法会调用 <code>org.apache.kafka.common.network.Selector</code> 的 <code>poll</code> 方法把请求真正发出去</li></ol><h3 id="KafkaProducer"><a href="#KafkaProducer" class="headerlink" title="KafkaProducer"></a>KafkaProducer</h3><p>KafkaProducer 是将消息发布到 Kafka 群集的 Kafka 客户端。producer 是线程安全的,跨线程共享单个 producer 实例通常比拥有多个实例更快。因此,我们使用 Springboot 集成 Kafka 的时候,默认只会创建一个 KafkaProducer 实例。</p><p>当我们通过 <code>org.springframework.kafka.core.KafkaTemplate#send</code> 发送消息时,实际会调用到的就是 <code>org.apache.kafka.clients.producer.KafkaProducer#doSend</code> 方法。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line">private Future<RecordMetadata> doSend(ProducerRecord<K, V> record, Callback callback) {</span><br><span class="line"> TopicPartition tp = null;</span><br><span class="line"></span><br><span class="line"> throwIfProducerClosed();</span><br><span class="line"> // 首选确保 topic 的元数据可用</span><br><span class="line"> ClusterAndWaitTime clusterAndWaitTime = waitOnMetadata(record.topic(), record.partition(), maxBlockTimeMs);</span><br><span class="line"> </span><br><span class="line"> long remainingWaitMs = Math.max(0, maxBlockTimeMs - clusterAndWaitTime.waitedOnMetadataMs);</span><br><span class="line"> Cluster cluster = clusterAndWaitTime.cluster;</span><br><span class="line"></span><br><span class="line"> // key 和 record 序列化</span><br><span class="line"> byte[] serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key());</span><br><span class="line"> byte[] serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value());</span><br><span class="line"> </span><br><span class="line"> // 根据发送的 record 计算发送到哪个分区</span><br><span class="line"> int partition = partition(record, serializedKey, serializedValue, cluster);</span><br><span class="line"> tp = new TopicPartition(record.topic(), partition);</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"> // 确保消息不能太大</span><br><span class="line"> int serializedSize = AbstractRecords.estimateSizeInBytesUpperBound(apiVersions.maxUsableProduceMagic(),</span><br><span class="line"> compressionType, serializedKey, serializedValue, headers);</span><br><span class="line"> ensureValidRecordSize(serializedSize);</span><br><span class="line"></span><br><span class="line"> // 将消息添加到消息累积器</span><br><span class="line"> RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey,</span><br><span class="line"> serializedValue, headers, interceptCallback, remainingWaitMs);</span><br><span class="line"></span><br><span class="line"> // 如果批次满了或者新建了一个批次,就会唤醒 sender 线程</span><br><span class="line"> if (result.batchIsFull || result.newBatchCreated) {</span><br><span class="line"> this.sender.wakeup();</span><br><span class="line"> }</span><br><span class="line"> return result.future;</span><br><span class="line"> }</span><br></pre></td></tr></table></figure><p>代码我精简了部分内容,可以看出发送方法主要就是干了以下几件事情:</p><ol><li>确保 topic 的元数据可用,如果元数据没有的话会先发起元数据请求,因为 KafkaProducer 需要知道一些元数据才能知道发送到哪个 broker 和哪个分区</li><li>对分区 key 和消息 record 进行序列化,我们可以通过 <code>key.serializer</code> 和 <code>value.serializer</code> 两个配置项自定义序列化方式</li><li>计算发送到 topic 的哪个分区,默认情况下如果没有指定分区 key,则会通过轮询的方式负载均衡发送到分区,如果指定了分区 key,则根据 key 的哈希对分区数取模计算出分区</li><li>确保消息的大小不能大于 <code>max.request.size</code> 和 <code>buffer.memory</code> 配置的值</li><li>将消息添加到记录累积器,这一步是整个方法的核心,在 Kafka 中消息的发送是按批次发送的,当然一个批次可以只有一条消息,这一步骤我们下面展开分析</li><li>如果批次满了或者新建了一个批次,就会唤醒 sender 线程,sender 线程会对消息进行发送</li></ol><p>另外,我们留意到 accumulator.append 方法还传入了一个 remainingWaitMs 参数,这是因为 Kafka 提供了一个 <code>max.block.ms</code> 参数来让我们控制 <code>KafkaProducer.send()</code> 和 <code>KafkaProducer.partitionsFor()</code> 方法最长的阻塞时间。</p><h3 id="RecordAccumulator"><a href="#RecordAccumulator" class="headerlink" title="RecordAccumulator"></a>RecordAccumulator</h3><p>RecordAccumulator 相当于一个队列,将要发送的记录累积要发送到服务器的 <code>MemoryRecords</code> 实例中。RecordAccumulator 是有内存限制的,当内存不够用的时候,append 就会被阻塞,这就是为什么会有 <code>max.block.ms</code> 这个配置项。</p><p>在开始看代码之前,我们先了解一个 RecordAccumulator 保存消息的数据结构:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">ConcurrentMap<TopicPartition, Deque<ProducerBatch>> batches;</span><br></pre></td></tr></table></figure><p>也就是说,每个主题的每个分区,都会对应一个双端队列,而队列里面保存的是 ProducerBatch ,ProducerBatch 就是保存一个批次消息的对象。因为 Kafka broker 中的消息是按分区来存储的,所有只有同一个分区的消息才会打成一个批次,这个应该很好理解。</p><p>现在,我们来看一下 <code>RecordAccumulator.append</code> 方法: </p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br></pre></td><td class="code"><pre><span class="line">public RecordAppendResult append(TopicPartition tp,</span><br><span class="line"> long timestamp,</span><br><span class="line"> byte[] key,</span><br><span class="line"> byte[] value,</span><br><span class="line"> Header[] headers,</span><br><span class="line"> Callback callback,</span><br><span class="line"> long maxTimeToBlock) throws InterruptedException {</span><br><span class="line"> ByteBuffer buffer = null;</span><br><span class="line"> if (headers == null) headers = Record.EMPTY_HEADERS;</span><br><span class="line"> try {</span><br><span class="line"> // 检查我们是否有在进行中的 batch ,如果有就添加到一个 ProducerBatch 中</span><br><span class="line"> Deque<ProducerBatch> dq = getOrCreateDeque(tp);</span><br><span class="line"> synchronized (dq) {</span><br><span class="line"> RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callback, dq);</span><br><span class="line"> if (appendResult != null)</span><br><span class="line"> return appendResult;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 如果没有进行中的 record batch ,那么就创建一个新的 batch,开辟内存的大小为 batch.size 和本条记录的最大值</span><br><span class="line"> byte maxUsableMagic = apiVersions.maxUsableProduceMagic();</span><br><span class="line"> int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(maxUsableMagic, compression, key, value, headers));</span><br><span class="line"> buffer = free.allocate(size, maxTimeToBlock);</span><br><span class="line"></span><br><span class="line"> synchronized (dq) {</span><br><span class="line">// 由于 dq 是同步的,所以在多线程并发的时候,其他线程可能已经帮我们创建出一个新的 ProducerBatch,所以进入同步代码块之后需要再次尝试 append</span><br><span class="line"> RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callback, dq);</span><br><span class="line"> if (appendResult != null) {</span><br><span class="line"> return appendResult;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 创建新的 ProducerBatch 并将消息添加到该 ProducerBatch 中</span><br><span class="line"> MemoryRecordsBuilder recordsBuilder = recordsBuilder(buffer, maxUsableMagic);</span><br><span class="line"> ProducerBatch batch = new ProducerBatch(tp, recordsBuilder, time.milliseconds());</span><br><span class="line"> FutureRecordMetadata future = Utils.notNull(batch.tryAppend(timestamp, key, value, headers, callback, time.milliseconds()));</span><br><span class="line"></span><br><span class="line"> // 将 ProducerBatch 添加到 Deque 中</span><br><span class="line"> dq.addLast(batch);</span><br><span class="line"> incomplete.add(batch);</span><br><span class="line"></span><br><span class="line"> // Don't deallocate this buffer in the finally block as it's being used in the record batch</span><br><span class="line"> buffer = null;</span><br><span class="line"> return new RecordAppendResult(future, dq.size() > 1 || batch.isFull(), true);</span><br><span class="line"> }</span><br><span class="line"> } finally {</span><br><span class="line"> if (buffer != null)</span><br><span class="line"> free.deallocate(buffer);</span><br><span class="line"> }</span><br><span class="line"> }</span><br></pre></td></tr></table></figure><p>主要逻辑应该还是比较清晰的:</p><ol><li>首先获取到该 <code>TopicPartition</code> 对应的 <code>Deque<ProducerBatch></code> ,然后 <code>tryAppend</code> 方法中会取出 Deque<ProducerBatch> 中最后一个 <code>ProducerBatch</code>,如果不为空则尝试添加到该 ProducerBatch 中,如果为空则继续往下走</li><li>没有可用 ProducerBatch ,则创建一个新的,开辟内存的大小为 <code>batch.size</code> 和本条记录的最大值</li><li>同步代码块中会再次调用 <code>tryAppend</code> 方法,是因为在多线程并发的时候,其他线程可能已经帮我们创建出一个新的 ProducerBatch</li><li>创建新的 ProducerBatch 并将消息添加到该 ProducerBatch 中,消息实际是通过 <code>MemoryRecordsBuilder</code> 写到前面开辟的 <code>buffer</code> 中,其实就是写内存</li><li>返回 append 的结果,注意这个结果很重要,因为上面的 KafkaProducer 就是根据这个返回结果判断是否需要唤醒 sender 线程</li></ol><h3 id="Sender"><a href="#Sender" class="headerlink" title="Sender"></a>Sender</h3><p>Sender 是向 Kafka 集群发送生产请求的后台线程。该线程会发出元数据请求以更新其群集元数据,然后将产生的请求发送到适当的节点。跟踪代码会发现,Sender 线程是在 KafkaProducer 构造方法中启动的。</p><p>我们来看一下 Sender 线程的 <code>run</code> 方法:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">public void run() {</span><br><span class="line"> // 主循环,直接 close 方法被调用</span><br><span class="line"> while (running) {</span><br><span class="line"> try {</span><br><span class="line"> runOnce();</span><br><span class="line"> } catch (Exception e) {</span><br><span class="line"> log.error("Uncaught error in kafka producer I/O thread: ", e);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">void runOnce() {</span><br><span class="line"> long currentTimeMs = time.milliseconds();</span><br><span class="line"> long pollTimeout = sendProducerData(currentTimeMs);</span><br><span class="line"> client.poll(pollTimeout, currentTimeMs);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>简单的几行代码,包含的信息量其实是很大的:</p><ol><li>Sender 是不断地循环的,直到 close 方法被调用,意思就是不断地循环检查有没有数据需要发送</li><li><code>sendProducerData</code> 看名字就知道是发送数据</li><li><code>poll</code> 方法其实才是真正发生 socket 读写的地方,所以 sendProducerData 其实相当于构造请求,注册写事件</li></ol><p>接着看一下 sendProducerData 方法:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br></pre></td><td class="code"><pre><span class="line">private long sendProducerData(long now) {</span><br><span class="line"> Cluster cluster = metadata.fetch();</span><br><span class="line"> // 获取其分区准备好发送的节点列表</span><br><span class="line"> RecordAccumulator.ReadyCheckResult result = this.accumulator.ready(cluster, now);</span><br><span class="line"></span><br><span class="line"> // 如果有哪个分区的 Leader 分区未知,则强制元数据更新</span><br><span class="line"> if (!result.unknownLeaderTopics.isEmpty()) {</span><br><span class="line"> for (String topic : result.unknownLeaderTopics)</span><br><span class="line"> this.metadata.add(topic);</span><br><span class="line"> this.metadata.requestUpdate();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 移出掉还没有创建好连接的节点</span><br><span class="line"> Iterator<Node> iter = result.readyNodes.iterator();</span><br><span class="line"> long notReadyTimeout = Long.MAX_VALUE;</span><br><span class="line"> while (iter.hasNext()) {</span><br><span class="line"> Node node = iter.next();</span><br><span class="line"> if (!this.client.ready(node, now)) {</span><br><span class="line"> iter.remove();</span><br><span class="line"> notReadyTimeout = Math.min(notReadyTimeout, this.client.pollDelayMs(node, now));</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 以节点为维度整理出需要发送的 ProducerBatch 列表</span><br><span class="line"> Map<Integer, List<ProducerBatch>> batches = this.accumulator.drain(cluster, result.readyNodes, this.maxRequestSize, now);</span><br><span class="line"> addToInflightBatches(batches);</span><br><span class="line"></span><br><span class="line"> // 单个分区顺序性保证</span><br><span class="line"> if (guaranteeMessageOrder) {</span><br><span class="line"> for (List<ProducerBatch> batchList : batches.values()) {</span><br><span class="line"> for (ProducerBatch batch : batchList)</span><br><span class="line"> this.accumulator.mutePartition(batch.topicPartition);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> accumulator.resetNextBatchExpiryTime();</span><br><span class="line"> List<ProducerBatch> expiredInflightBatches = getExpiredInflightBatches(now);</span><br><span class="line"> List<ProducerBatch> expiredBatches = this.accumulator.expiredBatches(now);</span><br><span class="line"> expiredBatches.addAll(expiredInflightBatches);</span><br><span class="line"></span><br><span class="line"> </span><br><span class="line"> if (!expiredBatches.isEmpty())</span><br><span class="line"> log.trace("Expired {} batches in accumulator", expiredBatches.size());</span><br><span class="line"> for (ProducerBatch expiredBatch : expiredBatches) {</span><br><span class="line"> String errorMessage = "Expiring " + expiredBatch.recordCount + " record(s) for " + expiredBatch.topicPartition</span><br><span class="line"> + ":" + (now - expiredBatch.createdMs) + " ms has passed since batch creation";</span><br><span class="line"> failBatch(expiredBatch, -1, NO_TIMESTAMP, new TimeoutException(errorMessage), false);</span><br><span class="line"> if (transactionManager != null && expiredBatch.inRetry()) {</span><br><span class="line"> transactionManager.markSequenceUnresolved(expiredBatch.topicPartition);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> sensors.updateProduceRequestMetrics(batches);</span><br><span class="line"></span><br><span class="line"> // 如果有任何节点准备发送+具有可发送数据,会使用0超时进行轮询,这样可以立即循环并尝试发送更多数据。</span><br><span class="line"> // 否则,超时将是下一批到期时间与检查数据可用性的延迟时间之间的较小值。</span><br><span class="line"> long pollTimeout = Math.min(result.nextReadyCheckDelayMs, notReadyTimeout);</span><br><span class="line"> pollTimeout = Math.min(pollTimeout, this.accumulator.nextExpiryTimeMs() - now);</span><br><span class="line"> pollTimeout = Math.max(pollTimeout, 0);</span><br><span class="line"> if (!result.readyNodes.isEmpty()) {</span><br><span class="line"> pollTimeout = 0;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 发送生产请求</span><br><span class="line"> sendProduceRequests(batches, now);</span><br><span class="line"> return pollTimeout;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>主要逻辑在代码上都有注解,需要解析的是返回值 <code>pollTimeout</code>。这个返回值会用于主循环的 <code>client.poll</code> 方法中,代表 poll 阻塞的时间。</p><ul><li>如果某个分区已经准备好发送,则阻塞时间为0;</li><li>如果某个分区已经积累了一些数据但尚未准备好,则阻塞时间为“现在”与其“延迟到期时间”的时差;</li><li>否则,阻塞为“现在”与“元数据到期时间”的时差;</li></ul><p>所以,当我们设置了 <code>linger.ms</code> 的值时,不需要等到批次满了也会根据这个延迟时间来进行发送。</p><p>接着我们看下 <code>sendProduceRequest</code> 方法:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">private void sendProduceRequest(long now, int destination, short acks, int timeout, List<ProducerBatch> batches) {</span><br><span class="line"> ...</span><br><span class="line"> ClientRequest clientRequest = client.newClientRequest(nodeId, requestBuilder, now, acks != 0,</span><br><span class="line"> requestTimeoutMs, callback);</span><br><span class="line"> client.send(clientRequest, now);</span><br><span class="line"> log.trace("Sent produce request to {}: {}", nodeId, requestBuilder);</span><br><span class="line"> }</span><br></pre></td></tr></table></figure><p>简单来讲,就是构造一个客户端请求 <code>ClientRequest</code> ,然后调用客户端的 send 方法(org.apache.kafka.clients.KafkaClient#send)进行发送。</p><h3 id="NetworkClient"><a href="#NetworkClient" class="headerlink" title="NetworkClient"></a>NetworkClient</h3><p><code>NetworkClient</code> 是 <code>KafkaClient</code> 的实现类,也就是说上面的 send 方法实际调用的就是 NetworkClient 的 send 方法。</p><p>NetworkClient 用于异步请求/响应网络IO的网络客户端,Kafka 的生产者和消费者就是通过这个类来进行网络操作。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">private void doSend(ClientRequest clientRequest, boolean isInternalRequest, long now, AbstractRequest request) {</span><br><span class="line"> String destination = clientRequest.destination();</span><br><span class="line"> RequestHeader header = clientRequest.makeHeader(request.version());</span><br><span class="line"> Send send = request.toSend(destination, header);</span><br><span class="line"> InFlightRequest inFlightRequest = new InFlightRequest(</span><br><span class="line"> clientRequest,</span><br><span class="line"> header,</span><br><span class="line"> isInternalRequest,</span><br><span class="line"> request,</span><br><span class="line"> send,</span><br><span class="line"> now);</span><br><span class="line"> this.inFlightRequests.add(inFlightRequest);</span><br><span class="line"> selector.send(send);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>该方法比较简单,构造一个发送到目标 broker 数据的 Send 实体,然后调用 <code>org.apache.kafka.common.network.Selector#send</code> 方法。</p><h3 id="Selector"><a href="#Selector" class="headerlink" title="Selector"></a>Selector</h3><p>Selector 其实是包装了 Java NIO, 是一个 nioSelector 接口,用于执行无阻塞的多连接网络I/O。与 <code>NetworkSend</code> 和 <code>NetworkReceive</code> 协同工作,以传输网络请求和响应。</p><p>我们先看了解一下其用法:</p><p>通过执行以下操作,可以创建一个连接添加到 nioSelector</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">selector.connect(42, new InetSocketAddress("google.com", server.port), 64000, 64000);</span><br></pre></td></tr></table></figure><p><code>connect</code> 方法的调用不会阻塞在创建TCP连接,因此 connect 方法仅开始启动连接。成功调用此方法并不意味着已建立有效连接。发送请求、接收响应、处理连接完成以及现有连接上的断开都是使用 <code>poll()</code> 调用完成的。</p><p>比如:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">nioSelector.send(new NetworkSend(myDestination, myBytes));</span><br><span class="line">nioSelector.send(new NetworkSend(myOtherDestination, myOtherBytes));</span><br><span class="line">nioSelector.poll(TIMEOUT_MS);</span><br></pre></td></tr></table></figure><p>了解 Selector 的用法之后,我们再看回 Sender 主循环的代码应该就是茅塞顿开的感觉:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">void runOnce() {</span><br><span class="line"> long currentTimeMs = time.milliseconds();</span><br><span class="line"> long pollTimeout = sendProducerData(currentTimeMs);</span><br><span class="line"> client.poll(pollTimeout, currentTimeMs);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>由于篇幅关系,到这里 poll 方法就暂不展开分析了。</p><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><p>我好像把总结写在文章开头了。</p>]]></content>
<summary type="html"><p>最近花了几天时间阅读了 Kafka producer 的源码,内部的细节问题还是挺多的,还没有一行行细扣,只是看了下主要的脉络,尝试梳理一下其中的逻辑。</p>
<p>主要流程涉及以下几个核心类:</p>
<ul>
<li>KafkaProducer</li>
<li>Re</summary>
</entry>
<entry>
<title>Kafka Replication</title>
<link href="http://ideajava.com/2020/01/28/kafka-replication/"/>
<id>http://ideajava.com/2020/01/28/kafka-replication/</id>
<published>2020-01-28T07:24:24.000Z</published>
<updated>2024-04-26T08:12:11.057Z</updated>
<content type="html"><![CDATA[<p>之前研究了一下 Redis 的主从复制,现在对比学习一下 Kafka 的数据复制。</p><h3 id="副本"><a href="#副本" class="headerlink" title="副本"></a>副本</h3><p>Kafka 用 Topic 来组织数据,每个 Topic 可以分为多个分区,每个分区可以有多个副本。Kafka 中的副本跟 Redis 类似,也是分为主从副本,主副本负责读写,从副本不处理客户端的请求,只是一个 BackUp;当然在 Redis 中主从副本可以做读写分离,但是 Kafka 是不支持读写分离的,也没必要去做读写分离(后面会解析)。</p><p>Kafka 中的副本类型:</p><ul><li><p>Leader 副本:<br>每个分区只有一个 Leader 副本。为了保证一致性,所有生产者请求和消费者请求都会经过这个副本。</p></li><li><p>Follower 副本:<br>除了 Leader 副本之外的都是 Follower 副本。Follower 副本不处理客户端请求,唯一的任务就是从 Leader 那里复制消息,当 Leader 挂了之后,其中一个 Follower 会被提升为 Leader。</p></li></ul><h3 id="ISR"><a href="#ISR" class="headerlink" title="ISR"></a>ISR</h3><p>在 Redis 中,主从副本保持同步是通过主副本向从副本发送命令进行同步的。而 Kafka 则是 Follower 副本向 Leader 副本发送获取数据的请求进行同步,这种请求与消费者为了读取消息发送的请求是一样的。</p><p>既然存在数据的复制,那肯定会存在副本间数据不一致的情况。Kafka 为了保证数据的可靠性,定义了<strong>同步的副本(in-sync replicas)</strong>,只有数据被写到了所有的同步副本中才能被消费者读到,这个同步的副本组成的集合称为 <strong>ISR</strong> 。</p><p>那么,哪些副本属于 ISR ? 首先 Leader 副本肯定是同步副本,对于 Follower 副本需要满足以下条件:</p><ul><li>与 Zookeeper 保持会话,即在过去 6s(可 <code>zookeeper.session.timeout.ms</code> 配置)内向 Zookeeper 发送过心跳。</li><li>在过去的 10s 内(可通过 <code>replica.lag.time.max.ms</code> 配置)从 Leader 那里获取过消息。</li><li>在过去的 10s 内从 Leader 那里获取过<strong>最新</strong>的消息。光从 Leader 那里获取消息是不够的,它还必须是几乎零延迟的。</li></ul><p>如果跟 Follower 副本不能满足以上任何一点,比如与 Zookeeper 断开连接,或者在 10s 内没有请求获取任何消息,或者获取消息滞后了 10s 以上,那么它就被认为是不同步的。一个不同步的副本通过与 Zookeeper 重新建立连接,并从 Leader 那里获取最新消息,可以重新变成同步的。</p><blockquote><p>如果一个或多个副本在同步和非同步状态之间快速切换,说明集群内部出现了问题,通常是 Java 不恰当的垃圾回收配置导致的。不恰当的垃圾回收配置会造成几秒钟的停顿,从而让 broker 与 Zookeeper 之间断开连接,最后变成不同步的,进而发生状态切换。</p></blockquote><h3 id="分区分配"><a href="#分区分配" class="headerlink" title="分区分配"></a>分区分配</h3><p>现在,我们已经大概了解了 Kafka 的副本机制,那这些副本在 broker 中是怎么保存的呢?</p><p>我们的目标是要均衡 broker 的负载,由于数据的读写都是通过 Leader 副本,所以 Leader 副本需要均衡地分配到不同的 broker 上。这样做的好处不仅是负载均衡了,而且当某一台 broker 挂掉之后不至于所有 Leader 副本都不能进行读写,受影响的只有部分 Leader 副本。</p><p>举个例子,假设我们有 3 个 broker,每个 Topic 有三个分区,每个分区有 3 个副本。</p><p>那么为了实现均衡分配,我们可以先随机选择一个 broker(假设是 0 ),然后使用轮询的方式将分区 Leader 副本分配到 broker 上。于是,分区 0 的 Leader 副本会在 broker 0 上,分区 1 的 Leader 副本会在 broker 1 上,分区 2 的 Leader 副本会在 broker 3 上,以此类推。 </p><p>Leader 副本分配完之后,依次分配 Follower 副本。如果分区 0 的 Leader 在 broker 0 上,那么它的第一个 Follower 副本会在 broker 1 上,第二个 Follower 副本会在 broker 2 上。分区 1 的 Leader 在 broker 1 上,那么它的第一个 Follower 副本在 broker 2 上,第二个 Follower 副本在 broker 0 上,以此类推。</p><p>最终一个 Topic 三个分区,三个副本分配的结果如下图所示:</p><p><img src="/image/kafka-replica.png" alt="Kafka Replication"></p><p>这也解析了上面说到的为什么 Kafka 不需要读写分离,因为负载已经均衡到每一个 broker 上了。</p><h3 id="首选-Leader"><a href="#首选-Leader" class="headerlink" title="首选 Leader"></a>首选 Leader</h3><p>我们试想一下上图的情景,假设 broker 2 挂了,那么 Leader 2 也就挂了,此时 Kafka 会从 broker 0 或者 broker 1 中选择一个 Follower 2 提升为 Leader。当 broker 2 再次启动起来之后,原来的 Leader 2 将变成 Follower 2,此时 broker 2 没有 Leader 副本了, broker 0 或者 broker 1 将会有两个 Leader,这样就会导致负载不均衡了。</p><p>为了解决上面的问题, Kafka 中引入<strong>首选 Leader</strong>的概念,也就是优先会成为 Leader 的副本。默认情况下,Kafka 的 <code>auto.leader.rebalance. enable</code> 被设为 true,它会检查首选 Leader 是不是当前 Leader ,如果不是,并且该副本是同步的,那么就会触发 Leader 选举,让首选 Leader 成为当前 Leader,让负载变得更均衡。</p><p><code>auto.leader.rebalance. enable</code> 参数还需要配合以下两个参数一起使用:</p><ul><li><code>leader.imbalance.check.interval.seconds</code>: 检查负载不均衡的时间间隔,默认 300 秒</li><li><code>leader.imbalance.per.broker.percentage</code>: 每个 broker 上 Leader 不均衡的比例超过多少才会触发选举首选 Leader,默认 10%</li></ul><p>那么,哪个是首选 Leader?Kafka 在创建 Topic 时选定的 Leader 就是分区的首选 Leader,我们可以使用 <code>kafka.topics.sh</code> 工具查看副本和分区的详细信息,清单里的第一个副本一般就是首选 Leader。不管当前 Leader 是哪一个副本,都不会改变这个事实,即使使用副本分配工具将副本重新分配给其他 broker。如果我们需要手动进行副本分配,第一个指定的副本就是首选 Leader,所以要确保首选 Leader 被均衡地分配到各个 broker 上。</p><h3 id="选举"><a href="#选举" class="headerlink" title="选举"></a>选举</h3><p>前面提到,当一个分区的 Leader 挂了之后,会选举一个 Follower 提升为 Leader。当然,Kafka 会优先从同步的副本中选择一个提升为 Leader ,这样就不会丢失数据,这个选举就是 “完全”的。但如果在首领不可用时其他副本都是不同步的,我们该怎么办?</p><p>这个时候,我们需要根据业务情况作出一个权衡,因为:</p><ul><li>如果不同步的副本不能被提升为新 Leader ,那么分区在旧 Leader (最后一个同步副本)恢复之前是不可用的。</li><li>如果不同步的副本可以被提升为新 Leader,那么在这个副本变为不同步之后写入旧 Leader 的消息会全部丢失,导致数据不一致。</li></ul><p>也就是说,我们需要在可用性和一致性之间做一个决策。Kafka 中提供了 <code>unclean.leader.election.enable</code> 参数让我们配置,如果把 <code>unclean.leader.election.enable</code> 设为 true,就是允许不同步的副本成为 Leader (也就是“不完全的选举”),那么我们将面临丢失消息的风险。如果把这个参数设为 false, 就要等待原先的 Leader 重新上线,从而降低了可用性。</p><h3 id="最小同步副本"><a href="#最小同步副本" class="headerlink" title="最小同步副本"></a>最小同步副本</h3><p>根据 Kafka 对可靠性保证的定义,消息只有在被写入到所有同步副本之后才被认为是已提交的,才能被客户端消费。但如果这里的“所有副本”只包含一个同步副本,那么在这个副本变为不可用时,数据就会丢失。</p><p>Kafka 提供了一个最小同步副本参数 <code>min.insync.replicas</code> 让我们配置。如果要确保已提交的数据被写入不止一个副本,就需要把最少同步副本数量设置为大一点的值。对于一个包含 3 个副本的 Topic ,如果 <code>min.insync.replicas</code> 被设为 2,那么至少要存在两个同步副本才能向分区写入数据。如果只有一个副本,那么 broker 就会停止接受生产者的请求,尝试发送数据的生产者会收到 <code>NotEnoughReplicasException</code> 异常。消费者仍然可以继续读取已有的数据。</p><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><ul><li>Kafka 副本分为 Leader 和 Follower</li><li>同步副本集合称为 ISR ,只有被写进所有同步副本的消息才能被消费者读取到</li><li>分区需要均衡地分配到每个 broker </li><li>Kafka 的首选 Leader 是为了解决 Leader 迁移之后引发的负载不均衡问题</li><li>默认可以进行“不完全”选举,需要根据业务情况在可用性和一致性之间进行权衡</li><li>当同步副本数量小于最小同步副本时,生产者写入数据会收到异常信息,消费者仍可读取已有数据</li></ul>]]></content>
<summary type="html"><p>之前研究了一下 Redis 的主从复制,现在对比学习一下 Kafka 的数据复制。</p>
<h3 id="副本"><a href="#副本" class="headerlink" title="副本"></a>副本</h3><p>Kafka 用 Topic 来组织数据,每</summary>
</entry>
<entry>
<title>Redis Sentinel Client</title>
<link href="http://ideajava.com/2020/01/20/sentinel-client/"/>
<id>http://ideajava.com/2020/01/20/sentinel-client/</id>
<published>2020-01-20T07:00:36.000Z</published>
<updated>2024-04-26T08:12:11.065Z</updated>
<content type="html"><![CDATA[<p>前几篇文章研究了 Redis 服务端的高可用方案 Sentinel 模式,今天我们来研究一下 Sentinel 的客户端工作原理。</p><p>本文以 SpringBoot 和 Jedis 为例,先介绍客户端的使用,然后分析工作流程。在开始阅读文章之前,请大家思考一个问题,Sentinel 模式下,如果主节点发生了切换,客户端是怎么知道的?</p><h3 id="SpringBoot-Redis-配置"><a href="#SpringBoot-Redis-配置" class="headerlink" title="SpringBoot Redis 配置"></a>SpringBoot Redis 配置</h3><p>SpringBoot 中使用 Redis 很简单,只需要引入相应的依赖和配置相关参数即可:</p><ul><li>依赖引入</li></ul><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><dependency></span><br><span class="line"><groupId>org.springframework.boot</groupId></span><br><span class="line"><artifactId>spring-boot-starter-data-redis</artifactId></span><br><span class="line"></dependency></span><br><span class="line"><dependency></span><br><span class="line"><groupId>redis.clients</groupId></span><br><span class="line"><artifactId>jedis</artifactId></span><br><span class="line"></dependency></span><br></pre></td></tr></table></figure><ul><li>配置</li></ul><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">spring:</span><br><span class="line"> redis:</span><br><span class="line">database: 0</span><br><span class="line">password: ${REDIS_PWD}</span><br><span class="line">jedis: </span><br><span class="line"> pool:</span><br><span class="line"> max-active: 8 </span><br><span class="line"> max-wait: -1 </span><br><span class="line"> max-idle: 8 </span><br><span class="line"> min-idle: 2 </span><br><span class="line">sentinel:</span><br><span class="line"> master: mymaster</span><br><span class="line"> nodes:</span><br><span class="line"> - sentinel-0:26379</span><br><span class="line"> - sentinel-1:26379</span><br><span class="line"> - sentinel-2:26379</span><br></pre></td></tr></table></figure><p>上面是一个示例配置,在使用 Sentinel 模式之后,我们并不是直接配置 Redis 服务器主节点的 host 和 port 了,因为主节点是有可能切换的,我们并不能写死一个主服务器的 host 和 port。取而代之,我们配置的是 Sentinel 节点的 host 和 port,所以我们可以大胆猜测在使用 Sentinel 之后客户端的工作流程:</p><ol><li>通过任一可用的 Sentinel 节点获取 Redis 主服务器 host 和 port</li><li>建立与 Redis 主服务器的连接池</li><li>通过连接池获取一个连接与主服务进行读写等操作</li><li>如果发生了主服务器切换,重建连接池的连接到新的主服务器</li></ol><p>又回到了文章开头的问题,客户端怎么知道主服务器发生了切换呢?以我浅薄的工作经验再次大胆猜测:</p><ol><li>不断地去问 Sentinel 主服务器有没有变啊?</li><li>要不 Sentinel 直接告诉我?Redis 不是有个发布订阅功能吗。</li></ol><p>嗯,上面的工作流程和切换问题都是我瞎猜的,带着问题去看源码是我一贯的作风。</p><h3 id="SpringBoot-Redis-启动流程"><a href="#SpringBoot-Redis-启动流程" class="headerlink" title="SpringBoot Redis 启动流程"></a>SpringBoot Redis 启动流程</h3><p>如果从使用的源头查看,比如 <code>redisTemplate.opsForValue().set("key1","value1");</code> ,跟踪其实现方法,我们会发现是通过一个 <code>RedisConnection</code> 进行操作。而 RedisConnection 是通过 <code>RedisConnectionFactory</code> 获取的,所以 RedisConnectionFactory 的初始化对于我们了解整个流程至关重要。</p><p>我们回头看一下,在 SpringBoot 中使用 Redis 只引入了依赖和配置参数,并没有编写任何一行代码,这其实是 SpringBoot 的 AutoConfiguration 帮我们“偷偷”干了活。由于篇幅原因这部分就不展开了,我画了一个大致的关系图:</p><p><img src="/image/springboot-autoconfiguration.png" alt="SpringBoot AutoConfigruation"></p><p>由上图可知,<code>RedisAutoConfiguration</code> 帮我们实现了 Redis 的自动装配,我们看下 RedisAutoConfiguration 顶部的注解内容:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">@Configuration(proxyBeanMethods = false)</span><br><span class="line">@ConditionalOnClass(RedisOperations.class)</span><br><span class="line">@EnableConfigurationProperties(RedisProperties.class)</span><br><span class="line">@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })</span><br><span class="line">public class RedisAutoConfiguration {</span><br><span class="line">...</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>Import 了另外两个配置类,由于我们的例子引入的是 Jedis 客户端,所以主要是看 JedisConnectionConfiguration 干了那些活。</p><h3 id="JedisConnectionConfiguration"><a href="#JedisConnectionConfiguration" class="headerlink" title="JedisConnectionConfiguration"></a>JedisConnectionConfiguration</h3><p>我们再来看一下 <code>JedisConnectionConfiguration</code> 的部分代码:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">@Configuration(proxyBeanMethods = false)</span><br><span class="line">@ConditionalOnClass({ GenericObjectPool.class, JedisConnection.class, Jedis.class })</span><br><span class="line">class JedisConnectionConfiguration extends RedisConnectionConfiguration {</span><br><span class="line">@Bean</span><br><span class="line">@ConditionalOnMissingBean(RedisConnectionFactory.class)</span><br><span class="line">JedisConnectionFactory redisConnectionFactory(</span><br><span class="line">ObjectProvider<JedisClientConfigurationBuilderCustomizer> builderCustomizers) throws UnknownHostException {</span><br><span class="line">return createJedisConnectionFactory(builderCustomizers);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">private JedisConnectionFactory createJedisConnectionFactory(</span><br><span class="line">ObjectProvider<JedisClientConfigurationBuilderCustomizer> builderCustomizers) {</span><br><span class="line">JedisClientConfiguration clientConfiguration = getJedisClientConfiguration(builderCustomizers);</span><br><span class="line">if (getSentinelConfig() != null) {</span><br><span class="line">return new JedisConnectionFactory(getSentinelConfig(), clientConfiguration);</span><br><span class="line">}</span><br><span class="line">...</span><br><span class="line">}</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>看到这里,我们就知道核心的入口了,这里创建了一个 JedisConnectionFactory ,顾名思义,我们需要用到的 Redis 连接就是从这个工厂类获取的。在第二个方法中,有个 <code>getSentinelConfig()</code> 方法,一看就知道这是获取我们文章开头的 Sentinel 配置。</p><p>所以,JedisConnectionConfiguration 的作用大致就是读取我们的文章开头的配置,然后初始化一个 <code>JedisConnectionFactory</code> 的 Bean。</p><h3 id="JedisConnectionFactory"><a href="#JedisConnectionFactory" class="headerlink" title="JedisConnectionFactory"></a>JedisConnectionFactory</h3><p>我们再来看一下创建 JedisConnectionFactory 的时候做了哪些事情。</p><p>从上面的方法看出,JedisConnectionFactory 是通过 <code>new JedisConnectionFactory(getSentinelConfig(), clientConfiguration)</code> 创建的,我们看下这个构造方法的内容:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">public JedisConnectionFactory(RedisSentinelConfiguration sentinelConfig, JedisClientConfiguration clientConfig) {</span><br><span class="line"></span><br><span class="line">this(clientConfig);</span><br><span class="line"></span><br><span class="line">Assert.notNull(sentinelConfig, "RedisSentinelConfiguration must not be null!");</span><br><span class="line"></span><br><span class="line">this.configuration = sentinelConfig;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">private JedisConnectionFactory(JedisClientConfiguration clientConfig) {</span><br><span class="line"></span><br><span class="line">Assert.notNull(clientConfig, "JedisClientConfiguration must not be null!");</span><br><span class="line"></span><br><span class="line">this.clientConfiguration = clientConfig;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>似乎没干啥,只是简单地设置了 clientConfiguration 和 configuration 两个属性。到这里思路好像断了,难道设置两个属性就能干活了吗?前面推论的连接池呢,按理说 JedisConnectionFactory 应该会创建一个连建池,然后获取连接的时候从连接池取一个可用连接出来,但现在并没有看到这样的代码。</p><p>此时,只能继续翻看一下 JedisConnectionFactory 还有哪些方法,果然有所发现,有一个属性设置之后的后置方法:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line">public void afterPropertiesSet() {</span><br><span class="line">...</span><br><span class="line">if (getUsePool() && !isRedisClusterAware()) {</span><br><span class="line">this.pool = createPool();</span><br><span class="line">}</span><br><span class="line">...</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">private Pool<Jedis> createPool() {</span><br><span class="line"></span><br><span class="line">if (isRedisSentinelAware()) {</span><br><span class="line">return createRedisSentinelPool((RedisSentinelConfiguration) this.configuration);</span><br><span class="line">}</span><br><span class="line">return createRedisPool();</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">protected Pool<Jedis> createRedisSentinelPool(RedisSentinelConfiguration config) {</span><br><span class="line"></span><br><span class="line">GenericObjectPoolConfig poolConfig = getPoolConfig() != null ? getPoolConfig() : new JedisPoolConfig();</span><br><span class="line">return new JedisSentinelPool(config.getMaster().getName(), convertToJedisSentinelSet(config.getSentinels()),</span><br><span class="line">poolConfig, getConnectTimeout(), getReadTimeout(), getPassword(), getDatabase(), getClientName());</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>可以看出,当我们配置了 Sentinel 信息之后,会创建一个 <code>JedisSentinelPool</code>。</p><h3 id="JedisSentinelPool"><a href="#JedisSentinelPool" class="headerlink" title="JedisSentinelPool"></a>JedisSentinelPool</h3><p>我们再来看下创建 JedisSentinelPool 的构造方法:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">public JedisSentinelPool(String masterName, Set<String> sentinels,</span><br><span class="line"> final GenericObjectPoolConfig poolConfig, final int connectionTimeout, final int soTimeout,</span><br><span class="line"> final String password, final int database, final String clientName) {</span><br><span class="line"> ...</span><br><span class="line"> HostAndPort master = initSentinels(sentinels, masterName);</span><br><span class="line"> initPool(master);</span><br><span class="line"> }</span><br></pre></td></tr></table></figure><p>到这里,大概就可以证实我们前两点猜想:</p><ol><li>通过 Sentinel 获取主节点的 host 和 port</li><li>初始化到主节点的连接池</li></ol><p>我们分别看下这两个方法是怎么实现的:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"> private HostAndPort initSentinels(Set<String> sentinels, final String masterName) {</span><br><span class="line"></span><br><span class="line"> HostAndPort master = null;</span><br><span class="line">...</span><br><span class="line"> for (String sentinel : sentinels) {</span><br><span class="line"> ...</span><br><span class="line"> List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);</span><br><span class="line">...</span><br><span class="line"> if (masterAddr == null || masterAddr.size() != 2) {</span><br><span class="line"> log.warn("Can not get master addr, master name: {}. Sentinel: {}", masterName, hap);</span><br><span class="line"> continue;</span><br><span class="line"> }</span><br><span class="line"> ...</span><br><span class="line"> master = toHostAndPort(masterAddr);</span><br><span class="line"> break;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> ...</span><br><span class="line"> for (String sentinel : sentinels) {</span><br><span class="line"> final HostAndPort hap = HostAndPort.parseString(sentinel);</span><br><span class="line"> MasterListener masterListener = new MasterListener(masterName, hap.getHost(), hap.getPort());</span><br><span class="line"> masterListener.setDaemon(true);</span><br><span class="line"> masterListeners.add(masterListener);</span><br><span class="line"> masterListener.start();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> return master;</span><br><span class="line"> }</span><br></pre></td></tr></table></figure><p>原来这里不仅仅从可用的 Sentinel 中获取主节点的 host 和 port,还为每个 Sentinel 注册了一个 <code>MasterListener</code> 的监听器,看情况就是这里告诉我们主节点变更了。为了不打断主分析流程,我们先不看这个监听器,接着看获取到主节点信息之后怎么初始化连接池:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line">private void initPool(HostAndPort master) {</span><br><span class="line"> synchronized(initPoolLock){</span><br><span class="line"> if (!master.equals(currentHostMaster)) {</span><br><span class="line"> currentHostMaster = master;</span><br><span class="line"> if (factory == null) {</span><br><span class="line"> factory = new JedisFactory(master.getHost(), master.getPort(), connectionTimeout,</span><br><span class="line"> soTimeout, password, database, clientName);</span><br><span class="line"> initPool(poolConfig, factory);</span><br><span class="line"> } else {</span><br><span class="line"> factory.setHostAndPort(currentHostMaster);</span><br><span class="line"> internalPool.clear();</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"> public void initPool(final GenericObjectPoolConfig poolConfig, PooledObjectFactory<T> factory) {</span><br><span class="line"> if (this.internalPool != null) {</span><br><span class="line"> try {</span><br><span class="line"> closeInternalPool();</span><br><span class="line"> } catch (Exception e) {</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> this.internalPool = new GenericObjectPool<T>(factory, poolConfig);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>嗯,判断入参的主节点是不是当前主节点,如果不是的话就会初始化连接池,第一次调用该方法时,当前主节点为空也会进行初始化(重写了 equals 方法),连接池使用的是 apache commons pool2。</p><p>获取主节点信息和初始化连接池我们已经了解了,接下来看看 initSentinel 时注册的 <code>MasterListener</code> 干了啥。</p><h3 id="MasterListener"><a href="#MasterListener" class="headerlink" title="MasterListener"></a>MasterListener</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><span class="line">protected class MasterListener extends Thread {</span><br><span class="line"> public void run() {</span><br><span class="line"></span><br><span class="line"> running.set(true);</span><br><span class="line"></span><br><span class="line"> while (running.get()) {</span><br><span class="line"></span><br><span class="line"> j = new Jedis(host, port);</span><br><span class="line"> ...</span><br><span class="line"> List<String> masterAddr = j.sentinelGetMasterAddrByName(masterName); </span><br><span class="line"> if (masterAddr == null || masterAddr.size() != 2) {</span><br><span class="line"> log.warn("Can not get master addr, master name: {}. Sentinel: {}:{}.",masterName,host,port);</span><br><span class="line"> }else{</span><br><span class="line"> initPool(toHostAndPort(masterAddr)); </span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> j.subscribe(new JedisPubSub() {</span><br><span class="line"> @Override</span><br><span class="line"> public void onMessage(String channel, String message) {</span><br><span class="line"></span><br><span class="line"> String[] switchMasterMsg = message.split(" ");</span><br><span class="line"></span><br><span class="line"> if (switchMasterMsg.length > 3) {</span><br><span class="line"></span><br><span class="line"> if (masterName.equals(switchMasterMsg[0])) {</span><br><span class="line"> initPool(toHostAndPort(Arrays.asList(switchMasterMsg[3], switchMasterMsg[4])));</span><br><span class="line"> } else {</span><br><span class="line"> ...</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> } else {</span><br><span class="line"> ...</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> }, "+switch-master");</span><br><span class="line"></span><br><span class="line">... </span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>到这里,文章开头的问题终于真相大白,原来是有单独的线程监听每个 Sentinel 的 <code>+switch-master</code> 主题,当 Sentinel 发生主节点切换时会通过 <code>+switch-master</code> 主题发布消息,然后 <code>MasterListener</code> 线程监听到之后重新初始化连接池。</p><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><p>我们回顾一下整个流程</p><ol><li>SpringBoot 通过 AutoConfiguration 自动装配创建 JedisConnectionFactory</li><li>JedisConnectionFactory 根据配置信息创建 JedisSentinelPool</li><li>JedisSentinelPool 通过 Sentinel 获取主节点信息,然后初始化连接池</li><li>JedisSentinelPool 在 initSentinel 时除了获取主节点信息,还会为每个 Sentinel 创建一个 MasterListener 监听器</li><li>MasterListener 通过监听 Sentinel 的 <code>+switch-master</code> 主题观测主节点的切换,然后创建连接池</li></ol>]]></content>
<summary type="html"><p>前几篇文章研究了 Redis 服务端的高可用方案 Sentinel 模式,今天我们来研究一下 Sentinel 的客户端工作原理。</p>
<p>本文以 SpringBoot 和 Jedis 为例,先介绍客户端的使用,然后分析工作流程。在开始阅读文章之前,请大家思考一个问题</summary>
</entry>
<entry>
<title>MQTT QoS</title>
<link href="http://ideajava.com/2020/01/14/mqtt-qos/"/>
<id>http://ideajava.com/2020/01/14/mqtt-qos/</id>
<published>2020-01-14T11:56:48.000Z</published>
<updated>2024-04-26T08:12:11.060Z</updated>
<content type="html"><![CDATA[<p>最近做了个物联网的项目,用到了 MQTT,里面有个 QoS 的概念理解起来可能会有一点困扰,今天花了点时间梳理了一下其中的逻辑,写篇文章记录一下。</p><h3 id="什么是-QoS"><a href="#什么是-QoS" class="headerlink" title="什么是 QoS ?"></a>什么是 QoS ?</h3><p>QoS 的全称是 quality of service。描述的是消息发送方和消息接收方之间消息传递的保证级别。MQTT 中有 3 个 QoS 级别:</p><ul><li>At most once (0)</li><li>At least once (1)</li><li>Exactly once (2)</li></ul><p>MQTT 发布消息 QoS 保证不是端到端的,是客户端与服务器之间的。订阅者收到 MQTT 消息的 QoS 级别,最终取决于发布消息的 QoS 和主题订阅的 QoS。</p><table><thead><tr><th align="center">发布消息的QoS</th><th align="center">主题订阅的QoS</th><th align="center">接收消息的QoS</th></tr></thead><tbody><tr><td align="center">0</td><td align="center">0</td><td align="center">0</td></tr><tr><td align="center">0</td><td align="center">1</td><td align="center">0</td></tr><tr><td align="center">0</td><td align="center">2</td><td align="center">0</td></tr><tr><td align="center">1</td><td align="center">0</td><td align="center">0</td></tr><tr><td align="center">1</td><td align="center">1</td><td align="center">1</td></tr><tr><td align="center">1</td><td align="center">2</td><td align="center">1</td></tr><tr><td align="center">2</td><td align="center">0</td><td align="center">0</td></tr><tr><td align="center">2</td><td align="center">1</td><td align="center">1</td></tr><tr><td align="center">2</td><td align="center">2</td><td align="center">2</td></tr></tbody></table><h3 id="QoS-0-at-most-once"><a href="#QoS-0-at-most-once" class="headerlink" title="QoS 0 - at most once"></a>QoS 0 - at most once</h3><p>最小 QoS 级别为 0 。此服务级别保证了 <em>尽力而为</em> 的投递,不保证一定投递成功。接收方不会回复确认消息,发送方也不存储和重新发送消息。</p><table><thead><tr><th align="center">发送方</th><th align="center">报文流向</th><th align="center">接受方</th></tr></thead><tbody><tr><td align="center">PUBLISH QoS = 0, DUP = 0</td><td align="center"></td><td align="center"></td></tr><tr><td align="center"></td><td align="center">—></td><td align="center"></td></tr><tr><td align="center"></td><td align="center"></td><td align="center">接收消息(可能不会收到)并处理</td></tr></tbody></table><h3 id="QoS-1-at-least-once"><a href="#QoS-1-at-least-once" class="headerlink" title="QoS 1 - at least once"></a>QoS 1 - at least once</h3><table><thead><tr><th align="center">发送方</th><th align="center">报文流向</th><th align="center">接受方</th></tr></thead><tbody><tr><td align="center">存储消息</td><td align="center"></td><td align="center"></td></tr><tr><td align="center">发送 PUBLISH QoS1, DUP = 0,带有 Packetld</td><td align="center"></td><td align="center"></td></tr><tr><td align="center"></td><td align="center">—></td><td align="center"></td></tr><tr><td align="center"></td><td align="center"></td><td align="center">接收消息并处理</td></tr><tr><td align="center"></td><td align="center"></td><td align="center">发送带有 Packetld 和 PUBACK 确认报文</td></tr><tr><td align="center"></td><td align="center"><—</td><td align="center"></td></tr><tr><td align="center">丢弃消息</td><td align="center"></td><td align="center"></td></tr></tbody></table><p>只要发送方没有收到 <code>PUBACK</code> 回复报文,消息还会被重新发送,所有 QoS 1 可能会出现消息重复的问题。</p><p>需要注意的是,接收方在接收 QoS 1 消息时并不会幂等处理,也就是说消息重发也认为是一条新的消息(至于 DUP 标志,协议里有说 “DUP = 1“ 时,也不能假设认为之前收到过该报文)。</p><h3 id="QoS-2-exactly-once"><a href="#QoS-2-exactly-once" class="headerlink" title="QoS 2 - exactly once"></a>QoS 2 - exactly once</h3><table><thead><tr><th align="center">发送方</th><th align="center">报文流向</th><th align="center">接受方</th></tr></thead><tbody><tr><td align="center">存储消息</td><td align="center"></td><td align="center"></td></tr><tr><td align="center">发送 PUBLISH QoS2, DUP = 0,带有 PacketId</td><td align="center"></td><td align="center"></td></tr><tr><td align="center"></td><td align="center">—></td><td align="center"></td></tr><tr><td align="center"></td><td align="center"></td><td align="center">存储 PacketId 和消息</td></tr><tr><td align="center"></td><td align="center"></td><td align="center">发布带有 Packetld 和 Reason Code 的 PUBREC 报文</td></tr><tr><td align="center"></td><td align="center"><—</td><td align="center"></td></tr><tr><td align="center">丢弃存储的消息,存储接收到的带有相同 PacketId 的 PUBREC 报文</td><td align="center"></td><td align="center"></td></tr><tr><td align="center">发送 PUBREL 报文</td><td align="center"></td><td align="center"></td></tr><tr><td align="center"></td><td align="center">—></td><td align="center"></td></tr><tr><td align="center"></td><td align="center"></td><td align="center">处理消息,然后丢弃 PacketId</td></tr><tr><td align="center"></td><td align="center"></td><td align="center">发送带有 PacketId 的 PUBCOMP 报文</td></tr><tr><td align="center"></td><td align="center"><—</td><td align="center"></td></tr><tr><td align="center">丢弃存储的状态</td><td align="center"></td><td align="center"></td></tr></tbody></table><p>QoS 2 应该是最难理解的了,涉及了四次交互,如下图所示</p><p><img src="/image/qos2.png" alt="QoS 2"></p><p>简单来说,QoS 2 有点像分布式事务中 <strong>两阶段提交</strong> 的解决方案,第一阶段的请求响应只是将消息保存起来的,但还不能作为正式的消息去处理,相当于一个 <strong>半提交</strong> 的状态,第二阶段就是确认消息提交,可以进行处理了。</p><p>QoS 2 保证只到达了一次是因为在这四次交互之中根据 PacketId 做了幂等处理,但是在处理消息之后 PacketId 会被丢弃,也就是说在发起新一轮的四次交互 PacketId 是可以重用的。至于 QoS 2 怎么实现幂等处理,大家找个 MQTT 客户端的源码看一下。</p><p>对于 QoS 2 如果还有疑问的可以继续看下下面两篇文章:</p><ul><li><a href="http://www.steves-internet-guide.com/understanding-mqtt-qos-2/">Understanding MQTT QOS Levels- Part 2</a></li><li><a href="https://www.zhihu.com/question/54000916">MQTT协议中QoS2为什么要四次包交互?</a></li></ul><p>当然,源码是最好的老师。</p>]]></content>
<summary type="html"><p>最近做了个物联网的项目,用到了 MQTT,里面有个 QoS 的概念理解起来可能会有一点困扰,今天花了点时间梳理了一下其中的逻辑,写篇文章记录一下。</p>
<h3 id="什么是-QoS"><a href="#什么是-QoS" class="headerlink" titl</summary>
</entry>
<entry>
<title>Redis 哨兵模式(二)</title>
<link href="http://ideajava.com/2020/01/10/redis-sentinel-2/"/>
<id>http://ideajava.com/2020/01/10/redis-sentinel-2/</id>
<published>2020-01-10T08:50:38.000Z</published>
<updated>2024-04-26T08:12:11.063Z</updated>
<content type="html"><![CDATA[<p>在上一篇文章中学习了 Sentinel 基本的数据结构和消息通信,现在我们来看一下监控和故障转移。</p><h3 id="检测主观下线"><a href="#检测主观下线" class="headerlink" title="检测主观下线"></a>检测主观下线</h3><p>众所周知,检测服务是否可用的常用方式就是通过发起一个请求,等待服务的响应,如果服务在某个规定的时间点内都没有响应,那么我们就会认为服务挂掉了。</p><p>Sentinel 检测主观下线也是采用类似的方式,在默认的情况下,Sentinel 会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器,从服务器和其他 Sentinel)发送 PING 命令,并通过响应信息来判断实例是否在线。</p><ul><li>有效响应:实例返回 +PONG, -LOADING, -MASTERDOWN 三种其中一种</li><li>无效响应:返回以上三种之外的的其他响应或者是没有响应</li></ul><p>Sentinel 配置文件中的 <code>down-after-milliseconds</code> 属性用于判断实例进入主观下线所需要的时间:即在 <code>down-after-milliseconds</code> 配置的毫秒数内,连续向 Sentinel 返回无效响应,则认为实例进入主观下线状态,此时会更新数据结构中的 flags 属性,打开 <code>SRI_S_DOWN</code> 标识。</p><p>比如,主服务器在 <code>down-after-milliseconds</code> 时间范围内没有返回有效响应,则数据结构会更新如下:</p><p><img src="/image/sri_s_down.png" alt="Sentinel network"></p><p><strong>注意:</strong></p><ul><li><code>down-after-milliseconds</code> 不仅用于判断主服务器主观下线,还用于从服务器和其他 Sentinel</li><li>每个 Sentinel 配置的 <code>down-after-milliseconds</code> 时间有可能不同,如果配置不同,会出现 Sentinel1 认为已经主观下线,而 Sentinel2 不这样认为</li></ul><h3 id="检测客观下线"><a href="#检测客观下线" class="headerlink" title="检测客观下线"></a>检测客观下线</h3><p>当 Sentinel 将一个主服务器判断为下线之后,为了确认该主服务是否真的下线,它会向同样监视这一主服务器的其他 Sentinel 进行询问,看它们是否也认为主服务已经进入了下线状态(可以是主观下线或者客观下线)。当 Sentinel 从其他 Sentinel 那里接受到足够数量的已下线判断,Sentinel 就会将主服务器判断为客观下线,并对其执行故障转移操作。</p><h4 id="发送-Sentinel-is-master-down-by-addr-命令"><a href="#发送-Sentinel-is-master-down-by-addr-命令" class="headerlink" title="发送 Sentinel is-master-down-by-addr 命令"></a>发送 Sentinel is-master-down-by-addr 命令</h4><p>Sentinel 通过使用:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Sentinel is-master-down-by-addr <ip> <port> <current_epoch> <runid></span><br></pre></td></tr></table></figure><p>命令询问其他 Sentinel 是否判断主服务已经下线,各参数意义如下:</p><ul><li>ip: 被 Sentinel 判断为主观下线的主服务器 IP 地址</li><li>port: 被 Sentinel 判断为主观下线的主服务器 PORT 地址</li><li>current_epoch: Sentinel 当前的配置纪元,用于选举 Leader Sentinel</li><li>run_id: 可以是 * 或者 Sentinel 的 run_id:* 代表仅用于检测主服务器的客观下线状态,而 run_id 则用于选举 Leader Sentinel</li></ul><h4 id="接收-Sentinel-is-master-down-by-addr-命令"><a href="#接收-Sentinel-is-master-down-by-addr-命令" class="headerlink" title="接收 Sentinel is-master-down-by-addr 命令"></a>接收 Sentinel is-master-down-by-addr 命令</h4><p>当一个 Sentinel 接收到另一个 Sentinel 的 <code>is-master-down-by-addr</code> 命令之后,会根据参数来检测主服务是否已经下线,然后返回一条包含以下三个参数的回复:</p><ul><li>down_status: 1 代表主服务已下线,0 代表未下线</li><li>leader_runid: 可以是 * 或者该响应 Sentinel 判断出的局部 Leader Sentinel run_id</li><li>leader_epoch: 仅在 leader_runid 不为 * 时有效,leader_runid 为 * 时总是 0</li></ul><h4 id="接收-Sentinel-is-master-down-by-addr-命令的回复"><a href="#接收-Sentinel-is-master-down-by-addr-命令的回复" class="headerlink" title="接收 Sentinel is-master-down-by-addr 命令的回复"></a>接收 Sentinel is-master-down-by-addr 命令的回复</h4><p>Sentinel 根据回复统计其他 Sentinel 判断主服务已下线的数量,当这一数量达到配置指定的判断客观下线所需的数量时,Sentinel 就会将主服务 sentinelRedisInstance 数据结构中的 flags 属性 SRI_O_DOWN 标识打开,表示主服务已经进入客观下线状态。</p><p><img src="/image/sri_o_down.png" alt="Sentinel network"></p><p><strong>注意:</strong> 不同的 Sentinel 配置判断客观下线的数量可能存在不同</p><h3 id="选举-Leader-Sentinel"><a href="#选举-Leader-Sentinel" class="headerlink" title="选举 Leader Sentinel"></a>选举 Leader Sentinel</h3><p>当一个主服务器被判断为客观下线时,监视这个下线主服务的各个 Sentinel 会进行协商,选举出一个 Leader Sentinel,并由 Leader Sentinel 对下线的主服务器执行故障转移。</p><p>选举规则和方法:</p><ul><li>所有在线的 Sentinel 都有被选举为 Leader 的资格</li><li>每次选举之后,不论选举是否成功,所有 Sentinel 的 epoch 都会自增一次</li><li>在一个配置纪元里面,所有 Sentinel 都有一次将某个 Sentinel 设置为局部 Leader Sentinel 的机会,并且设置之后再这个纪元之内就不能改变</li><li>每个发现主服务进入客观下线的 Sentinel 都会要求其他 Sentinel 将自己设置为局部 Leader Sentinel</li><li>当 Sentinel 向其他 Sentinel 发送 <code>is-master-down-by-addr</code> 命令中参数设置了自己的 run_id 就是让其他 Sentinel 选举自己为 Leader</li><li>Sentinel 设置局部 Leader 的规则是先到先得,后到的会被拒绝</li><li>Sentinel 会根据 <code>is-master-down-by-addr</code> 命令的回复取出 epoch 看是否跟自己的一致,如果一致再取出 leader_runid 看是否跟自己一直,如果一致则表示选举了自己为局部 Leader</li><li>如果某个 Sentinel 有超过半数的 Sentinel 设置自己为局部 Leader,那么这个 Sentinel 成为真正的 Leader</li><li>如果在规定的时间内没有选举出一个 Leader ,那么在一段时间之后再次选举,知道选举出一个 Leader 为止</li></ul><p>有熟悉的读者可能已经看出,这就是 Raft 选举算法。</p><h3 id="故障转移"><a href="#故障转移" class="headerlink" title="故障转移"></a>故障转移</h3><p>选举出 Leader 之后,Leader 将会对已下线的主服务器执行故障转移操作,该操作包含三个步骤:</p><ol><li>挑选一个从服务器作为新的主服务器</li><li>让其他从服务器改为复制新的主服务器</li><li>将已下线的主服务设置为新的主服务器的从服务器</li></ol><h4 id="选出新的主服务器"><a href="#选出新的主服务器" class="headerlink" title="选出新的主服务器"></a>选出新的主服务器</h4><p>故障转移的第一步就是挑选出一个状态良好,数据完整的从服务器,然后向其发送 SLAVEOF no one 命令,将这个从服务器转换为主服务器。</p><p>Leader 会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项项地对列表进行过滤:</p><ol><li>删除列表中处于下线或者断线状态的从服务器</li><li>删除列表中最近五秒没有回复过 Leader Sentinel 的 INFO 命令的从服务器</li><li>删除所有与已下线主服务器连接断开超过 down-after-milliseconds * 10 毫秒的从服务器</li></ol><p>之后,Leader 将根据从服务器的优先级,对列表中剩余的从服务器进行排序,选出优先级最高的从服务器。如果有相同最高优先级的,则选出偏移量最大的。如果偏移量也相同,则根据 run_id 排序选出最小的从服务器。</p><p>在向挑选出的从服务器发送 <code>SLAVEOF no one</code> 命令之后,Leader Sentinel 会以每秒一次的频率(平时是每十秒一次)向被升级的从服务发送 INFO 命令,并观察回复中的 role 信息。当被升级服务器的 role 从原来的 slave 变为 master 时,Leader 就知道被选中的从服务器已经升级为主服务器了。</p><h4 id="修改从服务器的复制目标"><a href="#修改从服务器的复制目标" class="headerlink" title="修改从服务器的复制目标"></a>修改从服务器的复制目标</h4><p>选举出新的主服务器之后,下一步就是向其他从服务器发送 SLAVEOF 命令,让它们复制新的主服务器。</p><h4 id="将旧的主服务器变为从服务器"><a href="#将旧的主服务器变为从服务器" class="headerlink" title="将旧的主服务器变为从服务器"></a>将旧的主服务器变为从服务器</h4><p>因为旧的服务器已经下线,所以这个设置是保存在旧的主服务器 redisSentinelInstance 数据结构中。当旧的主服务器重新上线时,Sentinel 就会向它发送 SLAVEOF 命令,让它成为新的主服务器的从服务器。</p><h3 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h3><ul><li>Sentinel 通过每秒一次的 PING 命令来判断服务器是否主观下线</li><li>对于主观下线的主服务器,Sentinel 通过 <code>is-master-down-by-addr</code> 命令询问其他 Sentinel 是否也判断为主观下线</li><li>当有足够多的 Sentinel 判断主服务为主观下线则会设置为客观下线</li><li>当主服务器客观下线之后,Sentinel 会通过 Raft 算法选举出一个 Leader Sentinel 进行故障转移</li><li>故障转移三个步骤:选举新主,其他从服务器复制新主,旧服务器设置为新主的 Slave</li></ul>]]></content>
<summary type="html"><p>在上一篇文章中学习了 Sentinel 基本的数据结构和消息通信,现在我们来看一下监控和故障转移。</p>
<h3 id="检测主观下线"><a href="#检测主观下线" class="headerlink" title="检测主观下线"></a>检测主观下线</h3></summary>
</entry>
<entry>
<title>Redis 哨兵模式(一)</title>
<link href="http://ideajava.com/2020/01/06/redis-sentinel-1/"/>
<id>http://ideajava.com/2020/01/06/redis-sentinel-1/</id>
<published>2020-01-06T11:17:12.000Z</published>
<updated>2024-04-26T08:12:11.062Z</updated>
<content type="html"><![CDATA[<p>上一篇文章研究了一下 Redis 的主从复制,如果再进一步思考,假设发生了主从切换的情况,客户端是怎么感知到,从而连接到主服务器进行读写操作呢?</p><p>今天我们要研究的 Sentinel(哨兵) 模式就是 Redis 高可用的一种方案:由一个或多个 Sentinel 实例组成的 Sentinel 系统,可以监控任意多个主服务器,以及这些主服务器下的所有从服务器。当监控到主服务器下线之后,可以自动将某个从服务器升级为新的主服务器。</p><p>由于篇幅原因,今天先来了解一下 Sentinel 的一些数据结构和消息通信的基本知识,有了今天的基础知识之后,下一篇文章再来了解如何检测服务器下线情况和故障转移。</p><h3 id="什么是-Sentinel"><a href="#什么是-Sentinel" class="headerlink" title="什么是 Sentinel"></a>什么是 Sentinel</h3><p>Redis Sentinel 其实就是一个比较特殊的 Redis 服务器,可以通过 <code>redis-sentinel /path/to/your/sentinel.conf</code> 或者 <code>redis-server /path/to/your/sentinel.conf --sentinel</code> 来启动。</p><p>当一个 Sentinel 启动时,它需要执行以下步骤:</p><ol><li>初始化服务器</li><li>将普通 Redis 服务器使用的代码替换成 Sentinel 代码</li><li>初始化 Sentinel 状态</li><li>根据给定的配置文件,初始化 Sentinel 的监控主服务器列表</li><li>创建与主服务器的网络连接</li></ol><p>从上面的步骤我们可以得到的信息:Sentinel 就是一个普通的 Redis 服务器替换了一些达到 Sentinel 功能的代码,根据配置文件初始化并监控主服务器。</p><p>既然要监控 Redis 服务器的情况,那么 Sentinel 肯定有数据结构来保存相关的信息,然后才根据相关信息来做决策。所以,我们要先了解 Sentinel 的数据结构。</p><h3 id="Sentinel-State"><a href="#Sentinel-State" class="headerlink" title="Sentinel State"></a>Sentinel State</h3><p>Sentinel 用一个叫做 sentinelState 的数据结构保存了服务器中所有和 Sentinel 功能有关的状态(服务器的一般状态仍然由 redisServer 数据结构来保存,因为 Sentinel 本质也是一个 Redis 服务器)</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">struct sentinelState {</span><br><span class="line">// 当前纪元,用于实现故障转移</span><br><span class="line">uint64_t current_epoch;</span><br><span class="line"></span><br><span class="line">// 保存了所以被这个 sentinel 监控的主服务器</span><br><span class="line">// 字典的 key 是主服务器的名称,在 sentinel.conf 配置文件中指定</span><br><span class="line">// 字典的值是一个指向 sentinelRedisInstance 结构的指针</span><br><span class="line">dict *masters;</span><br><span class="line">int tilt;</span><br><span class="line">int running_scripts;</span><br><span class="line">mstime_t tilt_start_time;</span><br><span class="line">mstime_t previous_time;</span><br><span class="line">list *scripts_queue;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>本次我们的主题只需要关注有注释的两个属性,其中重点就是 masters 属性,这里记录了 Sentinel 系统所监控的主服务器。这个时候大家可能就会有疑问了,Sentinel 不是还监控主服务下面的所有从服务器吗,为什么 sentinelState 这个数据结构没有 slaves 属性?这是因为 slaves 被保存到 sentinelRedisInstance 结构了。</p><h3 id="sentinelRedisInstance"><a href="#sentinelRedisInstance" class="headerlink" title="sentinelRedisInstance"></a>sentinelRedisInstance</h3><p>上面提到 Sentinel 监控的 masters 信息被保存在 sentinelRedisInstance 这个数据结构中。其实不仅是 master , slave 和 Sentinel 系统中的其他 Sentinel 节点信息也是用这个数据结构来保存,只是不同的角色所用到的属性不太一样。现在我们来看下这个数据结构的几个关键属性。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line">struct sentinelRedisInstance {</span><br><span class="line">// 标识值,记录了实例的类型,以及该实例的当前状态</span><br><span class="line">int flags;</span><br><span class="line"></span><br><span class="line">// 实例的名字</span><br><span class="line">// 主服务器的名字由用户在配置文件中指定</span><br><span class="line">// 从服务器以及 Sentinel 的名字由 Sentinel 自动设置,格式为 IP:PORT</span><br><span class="line">char *name;</span><br><span class="line"></span><br><span class="line">// 实例运行ID</span><br><span class="line">char *runid;</span><br><span class="line"></span><br><span class="line">// 配置纪元,用于实现故障转移</span><br><span class="line">uint64_t config_epoch;</span><br><span class="line"></span><br><span class="line">// 实例的地址,这个数据结构保存的是实例的 IP 和 PORT</span><br><span class="line">sentinelAddr *addr;</span><br><span class="line"></span><br><span class="line">// 实例多少毫秒没响应被认为主观下线,由 SENTINEL down-after-milliseconds 指定</span><br><span class="line">mstime_t down_after_period;</span><br><span class="line"></span><br><span class="line">// 多少个实例认为主观下线之后变为客观下线,即 SENTINEL monitor <master-name> <ip> <port> <quorum> 中的 quorum 参数</span><br><span class="line">int quorum;</span><br><span class="line"></span><br><span class="line">// 在执行故障转移时,可以同时对新的主服务器进行同步的从服务器数量</span><br><span class="line">int parallel_syncs;</span><br><span class="line"></span><br><span class="line">// 故障转移超时时间</span><br><span class="line">mstime_t failover_timeout;</span><br><span class="line"></span><br><span class="line">// 这个属性只有 master 才有,保存的是该 master 下属的所有 slave。key 是 ip:port,value 是 sentinelRedisInstance 指针</span><br><span class="line">dict *slaves;</span><br><span class="line"></span><br><span class="line">// 这个属性只有 master 才有,保存的是监控该 master的所有 Sentinel。key 是 ip:port,value 是 sentinelRedisInstance 指针</span><br><span class="line">dict *sentinels;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><h3 id="示例"><a href="#示例" class="headerlink" title="示例"></a>示例</h3><p>核心的两个数据结构我们已经有所了解,为了让大家更有体感,我们来画一下具有三个 Sentinel 和一主两备的 Redis 实例的结构图。</p><p>假设我们 sentinel.conf 配置文件中配置的 master 名字为 mymaster。</p><p><img src="/image/sentinel-deploy.png" alt="Sentinel deployment"></p><p>上面是部署结构图,三个 Sentinel 和一主两备的 Redis 实例,下面是数据结构。</p><p><img src="/image/sentinel-datastruct.png" alt="Sentinel data struct"></p><h3 id="获取主服务器信息"><a href="#获取主服务器信息" class="headerlink" title="获取主服务器信息"></a>获取主服务器信息</h3><p>看完上面的示例,大家可能会有个疑问,在初始化的时候,我们配置文件只配置了 master 的相关信息,并没有配置 slave 的信息,Sentinel 是怎么获取到 slave 的信息并保存在数据结构中的呢?</p><p>回想一下主从复制的部署过程,从服务会通过 SLAVEOF 命令来复制主服务器,建立了双方之间的联系,此时主服务器是有 slave 服务器的相关信息。所以,在 Sentinel 初始化的时候并不需要那么麻烦把 slave 服务器也配置到 sentinel.conf 配置文件中。</p><p>Sentinel 初始化的最后一步是创建到主服务器的网络连接,包含命令连接和订阅连接:</p><ul><li>命令连接:用于向主服务发送命令,并接收命令回复</li><li>订阅连接:用于订阅主服务器的 <code>__sentinel__:hello</code> 频道,这个作用后面会介绍</li></ul><p>Slave 服务器的信息就是通过命令连接来发现的,Sentinel 默认会以十秒一次的频率,通过向主服务发送 INFO 命令,主要可以获得以下两方面的信息:</p><ul><li>主服务器本身的信息,如 run_id 和 role</li><li>所有从服务器信息,包含 ip 和 port</li></ul><p>根据这些返回的信息,就可以更新上面的数据结构中的内容了。</p><h3 id="获取从服务器信息"><a href="#获取从服务器信息" class="headerlink" title="获取从服务器信息"></a>获取从服务器信息</h3><p>从主服务器信息中获取到所有从服务器的 ip 和 port 之后,Sentinel 也会创建到从所有从服务器的命令连接和订阅连接,也就是说 Sentinel 对所有监控的 Redis 服务器(不管主从)都会建立命令连接和订阅连接。</p><p><img src="/image/sentinel-link.png" alt="Sentinel network"></p><p>创建命令连接之后,也是十秒一次的频率向从服务器发送 INFO 命令获取相关信息:</p><ul><li>从服务器 run_id, role, slave_priority, slave_repl_offset</li><li>主服务器 master_host, master_port</li><li>主从服务器连接状态 master_link_status</li></ul><p>获取到这些信息之后更新从服务器的 sentinelRedisInstance。</p><h3 id="向主服务器和从服务器发送消息"><a href="#向主服务器和从服务器发送消息" class="headerlink" title="向主服务器和从服务器发送消息"></a>向主服务器和从服务器发送消息</h3><p>现在,我们已经知道 Sentinel 怎么发现 slave 服务器的了,但是现在另外一个疑问来了,Sentinel 是怎么发现其他 Sentinel 的呢?Sentinel 之间是需要互相通信的,这样才能进行故障转移等工作,所以 Sentinel 之间必须能够互相发现。</p><p>答案就是通过上面所说的订阅链接,从订阅的 <code>__sentinel__:hello</code> 频道接收到信息,这些信息包含了其他 Sentinel 的信息。那么 <code>__sentinel__:hello</code> 频道里面的信息是谁发送的呢?必然需要有客户端向 <code>__sentinel__:hello</code> 频道发送了消息,Sentinel 才能收到消息。</p><p>既然是通过订阅连接发现其他 Sentinel ,只有 Sentinel 本身知道自己的信息,所以转了一圈发送信息到 <code>__sentinel__:hello</code> 频道的也是 Sentinel。简单来说就是 Sentinel 通过 PUBLISH 命令向所有被监控的主服务器和从服务器的 <code>__sentinel__:hello</code> 频道发送消息,告诉别的 Sentinel 自身的相关信息。</p><p>在默认情况下,Sentinel 会以两秒一次的频率,通过命令连接向所有被监控的主服务器和从服务器发送以下格式的命令:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">PUBLISH `__sentinel__:hello` "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"</span><br></pre></td></tr></table></figure><p>其中 s_ 开头的参数是 Sentinel 本身的信息,而 m_ 开头的参数是主服务器的信息。</p><p>以我们上面的例子来说,当 Sentinel1, Sentinel2, Sentinel3, 都向他们所监控的所有主服务器和从服务器发送了 PUBLISH 命令之后,三个 Sentinel 都会通过订阅连接获取到各自发送的消息,包含自己发送的那一条。当一个 Sentinel 从 <code>__sentinel__:hello</code> 频道收到一条消息时,会对这条消息进行分析:</p><ul><li>如果 Sentinel runid 跟自己的 runid 相同,证明是自己发送的,则不作处理</li><li>如果不同,证明是其他 Sentinel 发送过来的,然后就更新数据结构</li></ul><h3 id="创建连向其他-Sentinel-的命令连接"><a href="#创建连向其他-Sentinel-的命令连接" class="headerlink" title="创建连向其他 Sentinel 的命令连接"></a>创建连向其他 Sentinel 的命令连接</h3><p>通过 <code>__sentinel__:hello</code> 频道发现了其他的 Sentinel ,那么为了和其他 Sentinel 通信,需要建立与其他 Sentinel 的命令连接。最终监控同一个主服务的多个 Sentinel 将形成互相连接的网络:</p><p><img src="/image/sentinel-network.png" alt="Sentinel network"></p><p>通过命令连接相连的各个 Sentinel 可以通过向其他 Sentinel 发送命令请求来进行信息交换,下一篇文章要介绍的检测主观下线、客观下线就是通过信息交换来实现的。</p><p>这里要注意的是,Sentinel 在连接主服务器和从服务器时,会同时创建命令连接和订阅连接,但是在连接其他 Sentinel 时,却只会创建命令连接,因为他们已经互相发现了,没必要用订阅连接。</p><h3 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h3><p>今天的文章由于篇幅关系其实还没有讲到核心功能,检测服务器状态和故障转移,但是有了这些基础之后就很简单了,现在做一个小结:</p><ul><li>Sentinel 是一个特殊的 Redis 服务器,通过配置文件获取要监控的 master 信息</li><li>Sentinel 通过向主服务发送 INFO 命令获取到 slave 信息</li><li>Sentinel 通过订阅 <code>__sentinel__:hello</code> 频道获取其他 Sentinel 信息</li><li>Sentinel 会向主服务和所属的从服务器建立命令连接和订阅连接,每十秒发送 INFO 命令,每两秒发送 PUBLISH 命令</li><li>Sentinel 之间只会建立命令连接</li></ul>]]></content>
<summary type="html"><p>上一篇文章研究了一下 Redis 的主从复制,如果再进一步思考,假设发生了主从切换的情况,客户端是怎么感知到,从而连接到主服务器进行读写操作呢?</p>
<p>今天我们要研究的 Sentinel(哨兵) 模式就是 Redis 高可用的一种方案:由一个或多个 Sentinel</summary>
</entry>
<entry>
<title>redis 主从复制</title>
<link href="http://ideajava.com/2019/12/24/redis-replication/"/>
<id>http://ideajava.com/2019/12/24/redis-replication/</id>
<published>2019-12-24T07:55:51.000Z</published>
<updated>2024-04-26T08:12:11.061Z</updated>
<content type="html"><![CDATA[<p>前段时间看了 《数据密集型应用系统设计》数据复制相关章节,相对来说比较理论,现在来看一个实际的例子,Redis 的主从复制。</p><p>在 Redis 中,可以通过 SLAVEOF 命令来让一个服务器去复制另一个服务器,被复制的服务器称为 Master,复制的服务器称为 Slave。</p><h3 id="复制的实现"><a href="#复制的实现" class="headerlink" title="复制的实现"></a>复制的实现</h3><p>Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作:</p><ul><li>同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。</li><li>命令传播操作用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致性状态。</li></ul><h4 id="同步"><a href="#同步" class="headerlink" title="同步"></a>同步</h4><p>当客户端向从服务器发送 SLAVEOF 命令,要求从服务器复制主服务器时,从服务器首先要执行同步操作,将从服务器的数据库状态更新至主服务器当前所处于的数据库状态。</p><p>同步操作分为全量同步和部分同步,在 Redis 2.8 之前只有全量同步(SYNC),Redis 2.8 之后有了部分同步 (PSYNC),Redis 4.0 之后对部分同步进行了优化 (为了区别称为 PSYNC2)。</p><p>我们先来看一下全量同步的过程:</p><ol><li>从服务器向主服务器发送 SYNC 命令。</li><li>收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从现在开始执行的所有写命令。</li><li>当主服务器的 BGSAVE 命令执行完毕时,主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器,从服务器接收并载入这个 RDB 文件,将自己的数据库状态更新至主服务器执行 BGSAVE 命令时的数据库状态。</li><li>主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令。</li></ol><h4 id="命令传播"><a href="#命令传播" class="headerlink" title="命令传播"></a>命令传播</h4><p>在同步操作执行完毕之后,主从服务器两者的数据库将达到一致状态,但这种一致并不是一成不变的,每当主服务器执行客户端发送的写命令时,主服务器的数据库就有可能会被修改,并导致主从服务器不再一致。</p><p>为了让主从服务器再次回到一致状态,主服务器需要对从服务器执行命令传播操作:主服务器会将自己执行的写命令发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态。</p><h4 id="部分同步"><a href="#部分同步" class="headerlink" title="部分同步"></a>部分同步</h4><p>相信大家都能想到,如果从服务器因为网络中断了比较短的时间,此时主从服务器的数据库差别可能并不是特别大,每次都要全量同步的话就非常浪费资源了。</p><p>在 Redis 2.8 之后引入了部分同步(PSYNC),部分同步功能由以下三个部分构成:</p><ol><li>主服务器的复制偏移量(replication offset)和从服务器的复制偏移量。</li><li>主服务器的复制积压缓冲区(replication backlog)。</li><li>服务器的运行 ID(run ID)。</li></ol><h5 id="复制偏移量"><a href="#复制偏移量" class="headerlink" title="复制偏移量"></a>复制偏移量</h5><p>执行复制的双方————主服务器和从服务器会分别维护一个复制偏移量:</p><ul><li>主服务器每次向从服务器传播 N 个字节的数据时,就将自己的复制偏移量的值加上 N。</li><li>从服务器每次收到主服务器传播来的 N 个字节数据时,就将自己的复制偏移量的值加上 N。</li></ul><h5 id="复制积压缓冲区"><a href="#复制积压缓冲区" class="headerlink" title="复制积压缓冲区"></a>复制积压缓冲区</h5><p>复制积压缓冲区是由主服务器维护的一个固定长度先进先出队列,默认大小为 1MB。</p><p>当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里面。因此,主服务器的复制积压缓冲区里面会保存一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量。</p><p>当从服务器重新连上主服务器时,从服务器会通过 PSYNC 命令将自己的复制偏移量 offset 发送给主服务器,主服务器会根据这个复制偏移量是否在复制积压缓冲区内来决定执行部分同步还是全量同步。</p><p><strong>注意:</strong> 复制积压缓冲区的大小需要根据实际情况调整,可以根据 second * write_size_per_second 来估算,为了安全起见可以设置为 2 * second * write_size_per_second。避免复制积压缓冲区溢出导致全量部分。</p><h5 id="运行-ID"><a href="#运行-ID" class="headerlink" title="运行 ID"></a>运行 ID</h5><p>除了复制偏移量和复制积压缓冲区之外,实现部分同步还需要用到服务器运行 ID(run ID)。</p><ul><li>每个 Redis 服务器,不论主服务器还是从服务器,都会有自己的运行 ID。</li><li>运行 ID 在服务器启动时自动生成,由 40 个随机的十六进制字符组成,例如 3426cd01f39d5ee2dc48969641423b0758b9d8fd。</li></ul><p>当从服务器对主服务器进行初次复制时,主服务器会将自己的运行 ID 传送给从服务器,而从服务器则会将这个运行 ID 保存起来。</p><p>当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行 ID:如果从服务器保存的运行 ID 和当前连接的主服务器的运行 ID 相同,那么说明从服务器断线之前复制的就是当前连接的这个主服务器,那么就可以尝试执行部分同步操作。</p><h5 id="PSYNC-命令的实现"><a href="#PSYNC-命令的实现" class="headerlink" title="PSYNC 命令的实现"></a>PSYNC 命令的实现</h5><p><img src="/image/psync.png" alt="Data replicate"></p><h4 id="部分同步优化"><a href="#部分同步优化" class="headerlink" title="部分同步优化"></a>部分同步优化</h4><p>大家应该也很容易想到上面的部分同步功能存在的缺陷,如果存在主从切换的情况,部分同步就无法实现了,因为主服务器变了,runid 不一样了只能执行全量同步了。</p><p>为了应对主从切换这种情况,Redis 4.0 对 PSYNC 进行了优化,引入了 master_replid 和 master_replid2 两个 ID。</p><ul><li>master_replid 指的是当前的复制 ID。与当前主服务器的 master_replid 一致。</li><li>master_replid2 指的是上一次的复制 ID。与上一次同步的主服务器的 master_replid 一致。</li></ul><p>master_replid 和 master_replid2 跟 runid 没有关系,只是生成的规则一样。也就是之前是根据 runid 是否相等来判断,优化之后根据 master_replid 或者 master_replid2 是否相等来判断。</p><p>试想一下,当主服务器挂了,从服务被提升为主服务器,此时的新主服务器 master_replid 是新生成的,master_replid2 就是上一次同步的主服务器的 master_replid ,也就挂掉哪台的 master_replid。当挂掉的服务器起来之后,角色变成了从服务器,此时用之前的 master_replid 来请求数据同步,新的主服务器会判断是否与 master_replid 或者 master_replid2 相等以及 offset 来判断是否可以进行部分同步。</p><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><ul><li>Redis 2.8 之前只有全量同步</li><li>Redis 2.8 之后通过复制偏移量,复制积压缓冲区,服务器运行 ID 引入了部分同步</li><li>Redis 4.0 之后通过 master_replid 和 master_replid2 解决主备切换全量同步的问题</li></ul><h3 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h3><ul><li><a href="https://www.jianshu.com/p/54dabc470eb6">Redis 4.0 解决全量同步问题</a></li><li><a href="https://www.cnblogs.com/kismetv/p/9236731.html">Redis 主从复制</a></li></ul>]]></content>
<summary type="html"><p>前段时间看了 《数据密集型应用系统设计》数据复制相关章节,相对来说比较理论,现在来看一个实际的例子,Redis 的主从复制。</p>
<p>在 Redis 中,可以通过 SLAVEOF 命令来让一个服务器去复制另一个服务器,被复制的服务器称为 Master,复制的服务器称为</summary>
</entry>
<entry>
<title>kube-proxy 与 service 之间的那些事</title>
<link href="http://ideajava.com/2019/11/29/kube-proxy-and-service/"/>
<id>http://ideajava.com/2019/11/29/kube-proxy-and-service/</id>
<published>2019-11-29T09:58:54.000Z</published>
<updated>2024-04-26T08:12:11.059Z</updated>
<content type="html"><![CDATA[<p>在 k8s 中,service 是一个和重要的概念。service 为一组相同的 pod 提供了统一的入口,并且能达到负载均衡的效果。</p><p>service 具有这样的功能,正是 kube-proxy 的功劳。当我们暴露一个 service 的时候,kube-proxy 会在 iptables 中追加一些规则,为我们实现了路由与负载均衡的功能。</p><p>下面用一个具体的小例子来看看 service 是怎么路由的。</p><h3 id="KUBE-SERVICES-链"><a href="#KUBE-SERVICES-链" class="headerlink" title="KUBE-SERVICES 链"></a>KUBE-SERVICES 链</h3><p>在我搭建的一个 minikube 环境中,运行 <code>iptables -t nat -nvL</code> 命令查看 iptables 中的内容。</p><p>首先,看下 PREROUTING 的规则</p><p><code>Chain PREROUTING (policy ACCEPT 5 packets, 271 bytes) pkts bytes target prot opt in out source destination 130K 6355K KUBE-SERVICES all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service portals */ 121K 5633K DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL</code></p><p>可以看出,所有请求都会先通过 k8s 追加的 KUBE-SERVICES 链,我们再看一下 KUBE-SERVICES 链中有什么规则</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">Chain KUBE-SERVICES (2 references)</span><br><span class="line"> pkts bytes target prot opt in out source destination</span><br><span class="line"> 0 0 KUBE-SVC-NPX46M4PTMTKRN6Y tcp -- * * 0.0.0.0/0 10.96.0.1 /* default/kubernetes:https cluster IP */ tcp dpt:443</span><br><span class="line"> 4 422 KUBE-SVC-TCOU7JCQXEZGVUNU udp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:dns cluster IP */ udp dpt:53</span><br><span class="line"> 0 0 KUBE-SVC-ERIFXISQEP7F7OF4 tcp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:dns-tcp cluster IP */ tcp dpt:53</span><br><span class="line"> 0 0 KUBE-SVC-JD5MR3NA4I4DYORP tcp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:metrics cluster IP */ tcp dpt:9153</span><br><span class="line"> 0 0 KUBE-SVC-MW5CXKKSAWLC5IQG tcp -- * * 0.0.0.0/0 10.102.64.24 /* default/device: cluster IP */ tcp dpt:8080</span><br><span class="line"> 0 0 KUBE-SVC-LKM3CLHDXK6GWQVY tcp -- * * 0.0.0.0/0 10.100.159.228 /* default/traffic: cluster IP */ tcp dpt:8080</span><br><span class="line"> 0 0 KUBE-SVC-2Q6H6PHDBHDVLI7U tcp -- * * 0.0.0.0/0 10.99.126.231 /* default/gateway: cluster IP */ tcp dpt:8080</span><br><span class="line"> 53 2720 KUBE-NODEPORTS all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>可以看到,有一系列目标为 KUBE-SVC-xxx 链的规则,每条规则都会匹配某个目标 ip 与端口。也就是说访问某个 ip:port 的请求会由 KUBE-SVC-xxx 链来处理。</p><p>这个目标 IP ,其实就是我 minikube 里面发布的 service ip,用 kubectl get svc -o wide 查看一下 demo 中的服务。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR</span><br><span class="line">device NodePort 10.102.64.24 <none> 8080:31748/TCP 5d19h app=device</span><br><span class="line">gateway ClusterIP 10.99.126.231 <none> 8080/TCP 2d1h app=gateway</span><br><span class="line">traffic ClusterIP 10.100.159.228 <none> 8080/TCP 2d19h app=traffic</span><br><span class="line">kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 7d7h <none></span><br></pre></td></tr></table></figure><p>比如 gateway 服务的 CLUSTER-IP 是 10.99.126.231,端口 8080。那么按照上面的 KUBE-SERVICES 链的规则,target 是 KUBE-SVC-2Q6H6PHDBHDVLI7U。我们再看一下 KUBE-SVC-2Q6H6PHDBHDVLI7U 链的内容</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Chain KUBE-SVC-2Q6H6PHDBHDVLI7U (1 references)</span><br><span class="line"> pkts bytes target prot opt in out source destination</span><br><span class="line"> 0 0 KUBE-SEP-2S4ZDBRDPHRMTYD5 all -- * * 0.0.0.0/0 0.0.0.0/0</span><br></pre></td></tr></table></figure><p>继续看下 KUBE-SEP-2S4ZDBRDPHRMTYD5 链的内容</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Chain KUBE-SEP-2S4ZDBRDPHRMTYD5 (1 references)</span><br><span class="line"> pkts bytes target prot opt in out source destination</span><br><span class="line"> 0 0 KUBE-MARK-MASQ all -- * * 172.17.0.8 0.0.0.0/0</span><br><span class="line"> 0 0 DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp to:172.17.0.8:8080</span><br></pre></td></tr></table></figure><p>会发现这条链做了一次目标网络地址转换 DNAT,将原来的的目标网络地址 service 的 CLUSTER-IP 10.99.126.231 端口 8080 转换成了 172.17.0.8:8080。此时,我们用 <code>kubectl get pod -o wide</code> 命令看一下 demo 中 pod 的 IP 地址</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES</span><br><span class="line">device-5b6d58dc76-ddtsm 1/1 Running 0 8h 172.17.0.10 minikube <none> <none></span><br><span class="line">device-5b6d58dc76-dk4kq 1/1 Running 0 2d23h 172.17.0.5 minikube <none> <none></span><br><span class="line">gateway-594c8d6cb5-25mc6 1/1 Running 7 2d5h 172.17.0.8 minikube <none> <none></span><br><span class="line">traffic-68cbcb88b9-qqrtk 1/1 Running 0 3d 172.17.0.9 minikube <none> <none></span><br></pre></td></tr></table></figure><p>172.17.0.8 就是我们 gateway service 的 endpoint,也就是我们想要调用的 pod。这就是为什么我们调用 service 的 CLUSTER-IP 和端口能访问到 pod 的原因了。</p><h3 id="KUBE-SEP-xxx-链"><a href="#KUBE-SEP-xxx-链" class="headerlink" title="KUBE-SEP-xxx 链"></a>KUBE-SEP-xxx 链</h3><p>上面的 KUBE-SEP-xxx 链其实就是 service 的 endpoint,那么 service 怎么实现负载均衡的呢。道理其实很简单,一个 service 的链对应多个 endpoint 即可,然后按照一定的比例随机调动。我在 demo 中部署了两个 device 服务的 endpoint,我们我看 kube-proxy 为我们生成的规则</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Chain KUBE-SVC-MW5CXKKSAWLC5IQG (2 references)</span><br><span class="line"> pkts bytes target prot opt in out source destination</span><br><span class="line"> 0 0 KUBE-SEP-B7QXYLUMAA6RGM2S all -- * * 0.0.0.0/0 0.0.0.0/0 statistic mode random probability 0.50000000000</span><br><span class="line"> 0 0 KUBE-SEP-EZG7Q3GTCBMWXIRX all -- * * 0.0.0.0/0 0.0.0.0/0</span><br></pre></td></tr></table></figure><p>50% 的概率会到 KUBE-SEP-B7QXYLUMAA6RGM2S,50% 概率到 KUBE-SEP-EZG7Q3GTCBMWXIRX。这两个目标正是 device 的两个 pod,如下所示</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Chain KUBE-SEP-B7QXYLUMAA6RGM2S (1 references)</span><br><span class="line"> pkts bytes target prot opt in out source destination</span><br><span class="line"> 0 0 KUBE-MARK-MASQ all -- * * 172.17.0.10 0.0.0.0/0</span><br><span class="line"> 0 0 DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp to:172.17.0.10:8080</span><br></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Chain KUBE-SEP-EZG7Q3GTCBMWXIRX (1 references)</span><br><span class="line"> pkts bytes target prot opt in out source destination</span><br><span class="line"> 0 0 KUBE-MARK-MASQ all -- * * 172.17.0.5 0.0.0.0/0</span><br><span class="line"> 0 0 DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp to:172.17.0.5:8080</span><br></pre></td></tr></table></figure><h3 id="KUBE-NODEPORTS-链"><a href="#KUBE-NODEPORTS-链" class="headerlink" title="KUBE-NODEPORTS 链"></a>KUBE-NODEPORTS 链</h3><p>回头再看看我们最初入口处的 KUBE-SERVICES,首先会判断是否匹配到了某个服务 KUBE-SVC-xxx。如果没有匹配到,最后还有 KUBE-NODEPORTS 链,匹配目标地址类型属于主机系统的本地网络地址的数据包。我们看下 demo 中 KUBE-NODEPORTS 链的内容</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Chain KUBE-NODEPORTS (1 references)</span><br><span class="line"> pkts bytes target prot opt in out source destination</span><br><span class="line"> 0 0 KUBE-MARK-MASQ tcp -- * * 0.0.0.0/0 0.0.0.0/0 /* default/device: */ tcp dpt:31748</span><br><span class="line"> 0 0 KUBE-SVC-MW5CXKKSAWLC5IQG tcp -- * * 0.0.0.0/0 0.0.0.0/0 /* default/device: */ tcp dpt:31748</span><br></pre></td></tr></table></figure><p>会发现只要是目标端口为 31748 的请求都会交给 KUBE-SVC-MW5CXKKSAWLC5IQG 链来处理,这条链就是我们 device service 。其实 31748 这个端口,就是我将 device 服务以 NodePort 类型发布随机分配的端口。看到这里,大家应该明白了为什么通过 NODEPORT 暴露的服务能在集群外部访问了。</p><p>集群外部:通过节点 IP 加 nodePort 可以访问到 device service<br>集群内部:通过 device service 的 CLUSTER-IP 和 port 能访问到 device service</p><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><ol><li>kube-proxy 会为我们暴露的 service 追加 iptables 规则</li><li>所有进出请求都会经过 KUBE-SERVICES 链</li><li>KUBE-SERVICES 链有我们发布的每一个服务,每个服务的链会对应到 DNAT 到 service 的 endpoint</li><li>KUBE-SERVICES 链最后的是 KUBE-NODEPORTS 链,会匹配请求本主机的一些端口,这就是我们通过 NODEPORT 类型发布服务能在集群外部访问的原因</li></ol><h3 id="延伸资料"><a href="#延伸资料" class="headerlink" title="延伸资料"></a>延伸资料</h3><p><a href="https://www.zsythink.net/archives/1199">iptables 入门</a><br><a href="https://www.cnblogs.com/zclzhao/p/5081590.html">iptables 命令,规则介绍</a><br><a href="https://www.lijiaocn.com/%E9%A1%B9%E7%9B%AE/2017/03/27/Kubernetes-kube-proxy.html">kube-proxy 转发规则</a></p>]]></content>
<summary type="html"><p>在 k8s 中,service 是一个和重要的概念。service 为一组相同的 pod 提供了统一的入口,并且能达到负载均衡的效果。</p>
<p>service 具有这样的功能,正是 kube-proxy 的功劳。当我们暴露一个 service 的时候,kube-pro</summary>
</entry>
<entry>
<title>将应用从 SpringCloud 迁移到 k8s</title>
<link href="http://ideajava.com/2019/11/27/SpringCloud-to-K8S/"/>
<id>http://ideajava.com/2019/11/27/SpringCloud-to-K8S/</id>
<published>2019-11-27T02:41:56.000Z</published>
<updated>2024-04-26T08:12:11.030Z</updated>
<content type="html"><![CDATA[<p>最近花了几天时间看了一下 k8s 和 istio 的文档,对容器化运维以及服务网格有了基础的了解。俗话说读万卷书不如行万里路,于是先尝试用 minikube 练一下手,将现有了一个 Spring Cloud 项目迁移到 k8s 上来。</p><p>粗略地整理了一个整个流程,主要有以下几个改动点:</p><ol><li>代码与配置修改</li><li>编写 Dockerfile</li><li>安装 kubectl 和 minikube</li><li>创建 configmap 资源</li><li>创建 deployment 资源</li><li>创建 secret 资源</li><li>暴露服务给集群外部调用</li></ol><h3 id="代码与配置修改"><a href="#代码与配置修改" class="headerlink" title="代码与配置修改"></a>代码与配置修改</h3><p>代码与配置的修改点不多,这样充分说明从 SpringCloud 迁移到 k8s 是相当的友好。</p><ol><li>由于 k8s 有内置 dns 做服务地址解析以及 service 资源做负载均衡。所以我们不再需要 eureka 做服务注册与发现了,需要把 eureka 相关的依赖与注解删除掉。</li></ol><p>删除 eureka client 的 @EnableDiscoveryClient 注解以及以下依赖:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><dependency></span><br><span class="line"> <groupId>org.springframework.cloud</groupId></span><br><span class="line"> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId></span><br><span class="line"></dependency></span><br></pre></td></tr></table></figure><p>另外 <code>application.yml</code> 配置文件也要去掉 eureka server 相关配置。</p><ol start="2"><li>由于 k8s 服务之间的调用是通过服务名称:端口的形式,所以 feign client 上需用加上 url 参数,例如:</li></ol><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">@FeignClient(value = "xxxService", url = "xxxService:8080")</span><br></pre></td></tr></table></figure><p>xxxService 就是后面要部署的 k8s 服务名称,8080 端口是 xxxService k8s 服务暴露的端口,可以自定义。</p><ol start="3"><li>为了我们修改 springboot 的 application.yml 文件之后,k8s 能自动感知然后重新自动部署应用,我们可以用 configmap 资源来维护我们的配置文件。需要作出以下改动:</li></ol><p>微服务内部只保留 bootstrap.yml 文件,其他 application.yml 文件在统一的地方维护后续用于生成 configmap 资源。bootstrap.yml 文件示例:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line">spring:</span><br><span class="line"> application:</span><br><span class="line"> name: xxxService</span><br><span class="line"> profiles:</span><br><span class="line"> active: #spring.profiles.active#</span><br><span class="line"> servlet:</span><br><span class="line"> multipart:</span><br><span class="line"> max-request-size: 100MB</span><br><span class="line"> max-file-size: 100MB</span><br><span class="line"> devtools:</span><br><span class="line"> restart:</span><br><span class="line"> enabled: true # 设置开启热部署</span><br><span class="line"> cloud:</span><br><span class="line"> kubernetes:</span><br><span class="line"> reload:</span><br><span class="line"> enabled: true</span><br><span class="line"> mode: polling</span><br><span class="line"> period: 5000</span><br><span class="line"> config:</span><br><span class="line"> sources:</span><br><span class="line"> - name: ${spring.application.name}</span><br><span class="line">management:</span><br><span class="line"> endpoint:</span><br><span class="line"> restart:</span><br><span class="line"> enabled: true</span><br><span class="line"> health:</span><br><span class="line"> enabled: true</span><br><span class="line"> info:</span><br><span class="line"> enabled: true</span><br></pre></td></tr></table></figure><p>新增以下依赖:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><dependency></span><br><span class="line"> <groupId>org.springframework.cloud</groupId></span><br><span class="line"> <artifactId>spring-cloud-starter-kubernetes-config</artifactId></span><br><span class="line"> <version>1.0.1.RELEASE</version></span><br><span class="line"></dependency></span><br></pre></td></tr></table></figure><p>这个依赖的用途就是让 k8s 来管理我们的配置文件。</p><h3 id="编写-Dockerfile"><a href="#编写-Dockerfile" class="headerlink" title="编写 Dockerfile"></a>编写 Dockerfile</h3><p>我们需要将 springboot 应用打包成镜像,然后上传到镜像仓库中,后面部署到 k8s 中需要指定镜像地址。Dockerfile 文件示例:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">FROM openjdk:8-jdk-alpine</span><br><span class="line">VOLUME /tmp</span><br><span class="line">ADD xxxService-1.0.jar app.jar</span><br><span class="line">ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]</span><br></pre></td></tr></table></figure><p>将镜像打包到仓库这里就不展开了。</p><h3 id="安装-kubectl-和-minikube"><a href="#安装-kubectl-和-minikube" class="headerlink" title="安装 kubectl 和 minikube"></a>安装 kubectl 和 minikube</h3><p>这个步骤可以参考<a href="https://kubernetes.io/docs/tasks/tools/install-minikube/">官方文档</a></p><h3 id="创建-configmap-资源"><a href="#创建-configmap-资源" class="headerlink" title="创建 configmap 资源"></a>创建 configmap 资源</h3><p>假设我们将 springboot 的配置文件维护在 git 仓库中,我们就可以在安装 minikube 的机器上 git clone 下来,运行以下命令,就可以将配置文件维护在 configmap 中了。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">kubectl create configmap xxx --from-file=application.yml</span><br></pre></td></tr></table></figure><p>但是后面我们部署服务的时候,默认账户下 pod 应该是没有权限读取到 configmap 中的内容的,所以我们需要创建有权限的 serviceaccount。可以参考一下<a href="https://juejin.im/post/5d71b1b4518825462823825a">这篇文章</a></p><h3 id="创建-deployment-资源"><a href="#创建-deployment-资源" class="headerlink" title="创建 deployment 资源"></a>创建 deployment 资源</h3><p>创建 deployment 资源很简单,只要指定镜像地址,运行一个简单的命令即可。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">kubectl create deployment xxxService --image=镜像地址</span><br></pre></td></tr></table></figure><p>但是这样默认创建的 deployment 资源还是有点小问题的:</p><ol><li>还没有配置读取我们上面创建的 configmap</li><li>由于我用的是阿里云私有镜像仓库,没有配置用户密码会拉取镜像失败</li></ol><p>对于第一个问题,我们需要将 configmap 挂在到容器中。参考配置:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line">spec:</span><br><span class="line"> selector:</span><br><span class="line"> matchLabels:</span><br><span class="line"> app: demo</span><br><span class="line"> template:</span><br><span class="line"> metadata:</span><br><span class="line"> labels:</span><br><span class="line"> app: demo</span><br><span class="line"> spec:</span><br><span class="line"> containers:</span><br><span class="line"> - name: demo</span><br><span class="line"> image: xxx</span><br><span class="line"> imagePullPolicy: IfNotPresent</span><br><span class="line"> ports:</span><br><span class="line"> - containerPort: 8080</span><br><span class="line"> volumeMounts:</span><br><span class="line"> - mountPath: /deployments/config</span><br><span class="line"> name: easygo-device</span><br><span class="line"> readOnly: true</span><br><span class="line"> volumes:</span><br><span class="line"> - name: demo-config</span><br><span class="line"> configMap:</span><br><span class="line"> name: demo-configmap</span><br><span class="line"> items:</span><br><span class="line"> - key: application.yml</span><br><span class="line"> path: application.yml </span><br><span class="line"> imagePullSecrets:</span><br><span class="line"> - name: aliyun-docker-hub</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>对于第二问题,我们需要创建 secret 资源来保存我们私有仓库的用户名密码,然后配置在 deployment 资源上,即上面的 imagePullSecrets 属性。创建 secret 资源比较简单,见下面的命令。</p><h3 id="创建-secret-资源"><a href="#创建-secret-资源" class="headerlink" title="创建 secret 资源"></a>创建 secret 资源</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">kubectl create secret docker-registry 资源名称 --docker-server=仓库地址 --docker-username={UserName} --docker-password={Password} --docker-email={Email}</span><br></pre></td></tr></table></figure><p>资源名称就是上面配置的 aliyun-docker-hub ,名称可自定义。</p><h3 id="暴露服务给集群外部调用"><a href="#暴露服务给集群外部调用" class="headerlink" title="暴露服务给集群外部调用"></a>暴露服务给集群外部调用</h3><p>默认情况下,k8s 集群外部是无法访问到集群内部的服务,所以我们需要将服务暴露出去,方式有很多种。具体可以看下<a href="https://jimmysong.io/posts/accessing-kubernetes-pods-from-outside-of-the-cluster/">这篇文章</a></p><p>最简单的方式是 NodePort,比如将 xxx 服务暴露出去,运行以下命令即可:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">kubectl expose deployment xxx --type=NodePort --port=8080</span><br></pre></td></tr></table></figure><p>然后我们就可以通过节点IP加上面暴露的端口即可访问。</p><p>而常用的方式是通过 Ingress 资源暴露,需要创建 Ingress 资源和部署 Ingress Controller。</p><p>先看一下官方的 <a href="https://kubernetes.io/zh/docs/concepts/services-networking/ingress/">Ingrss 介绍</a></p><p>然后 Ingress Controller 可以用 nginx ingress,部署方式见<a href="https://kubernetes.github.io/ingress-nginx/deploy/">官方文档</a></p><h3 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h3><p>本文只是介绍了 SpringCloud 迁移到 k8s 的大致流程,不是很完善,按照上面的步骤来弄应该还是会踩点坑。</p>]]></content>
<summary type="html"><p>最近花了几天时间看了一下 k8s 和 istio 的文档,对容器化运维以及服务网格有了基础的了解。俗话说读万卷书不如行万里路,于是先尝试用 minikube 练一下手,将现有了一个 Spring Cloud 项目迁移到 k8s 上来。</p>
<p>粗略地整理了一个整个流程</summary>
</entry>
<entry>
<title>数据复制——多主</title>
<link href="http://ideajava.com/2019/10/30/mutiple-master-node-data-copy/"/>
<id>http://ideajava.com/2019/10/30/mutiple-master-node-data-copy/</id>
<published>2019-10-30T07:31:55.000Z</published>
<updated>2024-04-26T08:12:11.060Z</updated>
<content type="html"><![CDATA[<p>分布式造飞机理论大全——《数据密集型应用系统设计》,读书笔记 2.</p><p><img src="/image/mutiple-master-node-data-copy.png" alt="Data replicate"></p>]]></content>
<summary type="html"><p>分布式造飞机理论大全——《数据密集型应用系统设计》,读书笔记 2.</p>
<p><img src="/image/mutiple-master-node-data-copy.png" alt="Data replicate"></p>
</summary>
</entry>
<entry>
<title>数据复制——主从</title>
<link href="http://ideajava.com/2019/10/21/Data-replicate-master-slave/"/>
<id>http://ideajava.com/2019/10/21/Data-replicate-master-slave/</id>
<published>2019-10-21T12:12:42.000Z</published>
<updated>2024-04-26T08:12:10.985Z</updated>
<content type="html"><![CDATA[<p>最近在研读分布式造飞机理论大全——《数据密集型应用系统设计》,做了一点读书笔记(抄书),偷懒直接上图了。</p><p><img src="/image/data-replicate-master-slave.png" alt="Master slave"></p><p><img src="/image/data-replicate.png" alt="Data replicate"></p>]]></content>
<summary type="html"><p>最近在研读分布式造飞机理论大全——《数据密集型应用系统设计》,做了一点读书笔记(抄书),偷懒直接上图了。</p>
<p><img src="/image/data-replicate-master-slave.png" alt="Master slave"></p>
<p></summary>
</entry>
<entry>
<title>数据库隔离级别温故而知新</title>
<link href="http://ideajava.com/2019/09/26/transaction-isolation/"/>
<id>http://ideajava.com/2019/09/26/transaction-isolation/</id>
<published>2019-09-26T12:48:55.000Z</published>
<updated>2024-04-26T08:12:11.067Z</updated>
<content type="html"><![CDATA[<p>在工作中,碰到一些问题的时候你会想,诶,这个知识点我似乎没理解透。数据库的隔离级别就是我最近有点困惑的知识点,大家试着回答以下问题:</p><ol><li><p>数据库的隔离级别究竟是为什么解决什么出题而出现的?</p></li><li><p>数据库的隔离级别是怎么实现的?</p></li><li><p>数据库的隔离级别跟锁有什么关系,有了数据库的隔离级别,我们平时在写 SQL 的时候还需要手工加锁吗?</p></li><li><p>为什么 MySQL 的 MVCC 机制在 RC 隔离级别下没有解决不可重复读的问题?</p></li></ol><p>…</p><p>不断地去思考,不断地去刨根问底,我们才能真正的把一个知识理解透。 </p><h4 id="数据库的隔离级别"><a href="#数据库的隔离级别" class="headerlink" title="数据库的隔离级别"></a>数据库的隔离级别</h4><p>我们先来看一下数据库定义的几种隔离级别,偷个懒直接网上找个图:</p><p><img src="/image/isolation-level.jpg" alt="isolation-level"></p><p>我们发现,隔离级别的关键字是<strong>“读”</strong>。也就是说,隔离级别的作用是为了有效保证并发<strong>读取</strong>数据的正确性。这就回答了第一个问题。</p><h4 id="数据库的隔离级别实现"><a href="#数据库的隔离级别实现" class="headerlink" title="数据库的隔离级别实现"></a>数据库的隔离级别实现</h4><p>那么,这几种隔离级别怎么实现的呢?我们可以先试想一下,如果用读写锁来实现,要怎么加锁才能达到效果?</p><p><strong>注意:</strong>这里是假设用锁来实现,MySQL 的实现原理实际并不是这样的,下面我们会谈到。</p><h5 id="脏读问题"><a href="#脏读问题" class="headerlink" title="脏读问题"></a>脏读问题</h5><p>事务1更新了一条数据,但是还没提交,此时事务2读取了这条数据,然后事务1因为某些原因进行了回滚,此时事务2读取到的数据其实是不对的,读到了脏数据就称之为脏读。(注:这就是读未提交隔离级别,能读取到未提交的数据,会发生脏读。读未提交不需要解析实现原理了吧,啥都没限制,直接读直接写就是了)</p><p>那么,我们怎么避免脏读?</p><p>如果用锁来实现的话,我们先来做个约定:更新修改删除数据要加上写锁,读取数据要加上读锁,读锁和写锁是互斥的,读锁和读锁是不互斥的。读锁读完数据就释放,写锁事务提交释放。</p><p>有了这个约定,我们就可以这样来避免脏读了:修改数据我们先加上写锁,读取数据要加上读锁,因为事务1还没提交,写锁还没有释放,此时事务2没法加上读锁,这样就读不到数据,避免了脏读。(注:这就是读已提交隔离级别用锁来实现的原理)</p><h5 id="不可重复读问题"><a href="#不可重复读问题" class="headerlink" title="不可重复读问题"></a>不可重复读问题</h5><p>事务1读取了一条数据,此时事务2更新了这条数据,然后事务1再读取这条数据,发现自己两次读取的内容有点不一样,这就是不可重复读。(注:读已提交隔离级别用锁来实现虽然解决了脏读,但是会存在不可以重复读)</p><p>那么,我们怎么避免不可重复读?</p><p>我们回想一下发送不可重复读的原因,正是因为我们之前设定读完数据就释放读锁,所以事务2才加上了写锁对数据进行了修改,如果事务1第一次读完数据不释放读锁,事务2就加不上写锁,就不会出现这个问题了。好吧,那读锁也事务提交的时候再释放吧,这样就不会发生不可重复读问题了。(注:这就是可重复读用锁来实现的原理了)</p><h5 id="幻读问题"><a href="#幻读问题" class="headerlink" title="幻读问题"></a>幻读问题</h5><p>事务1根据查询条件读取了若干条数据,事务2插入了符合事务1查询条件的数据,此时事务1再查一次,发现记录竟然多了一条,这就是幻读。(注:用锁来实现的可重复读虽然解决了不可重复读的问题,但是会存在幻读,因为新插入的数据并没有锁定)</p><p>那么,我们怎么避免幻读问题?</p><p>既然新插入数据导致我幻读,那我就想办法让他不能插入就行了。我们不仅把符合查询条件的记录锁起来,还把记录前后的 GAP 锁起来,这样其他事务就无法插入新数据了。(注:这就是序列化隔离级别了,你回想一下,这其实就是我在干活的时候,其他事务任何操作都做不了了,只能等我干完了才行)</p><h4 id="MVCC"><a href="#MVCC" class="headerlink" title="MVCC"></a>MVCC</h4><p>实际上, MySQL 并不会这样实现,因为锁就是性能杀手。在 MySQL InnoDB 存储引擎中,使用的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control,类似我们上面的推导)。</p><p>MVCC最大的好处:<strong>读不加锁,读写不冲突</strong>。</p><p>注意:在 MySQL InnoDB 存储引擎,MVCC 只用于 RC,RR 两个隔离级别。序列化隔离级别跟我们上面的推导是一样的,读加读锁,写加写锁,读写互斥,性能杀手。</p><p>在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。</p><p>那啥是快照读,啥是当前读?以MySQL InnoDB为例:</p><ul><li><p><strong>快照读</strong>:简单的select操作,属于快照读,不加锁。(当然,也有例外,下面会分析)<br> select * from table where ?;</p></li><li><p><strong>当前读</strong>:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。<br> select * from table where ? lock in share mode;<br> select * from table where ? for update;<br> insert into table values (…);<br> update table set ? where ?;<br> delete from table where ?;</p></li></ul><p>所有以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。</p><p>对于 MVCC 的实现原理就不展开分析了,可以参考文末的引用链接。</p><p>以上就回答了文章开头的第二个问题。</p><h4 id="隔离级别与锁的关系"><a href="#隔离级别与锁的关系" class="headerlink" title="隔离级别与锁的关系"></a>隔离级别与锁的关系</h4><p>从上面的分析来看,隔离级别有用到锁。那么我们在平时写业务代码的时候还需要手工加锁吗?</p><p>我们试想一个购票的业务方法:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">{</span><br><span class="line">1. 查询余票(select 余票 from xxxx)</span><br><span class="line">2. 余票扣减(update xxxx set 余票=余票-1)</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>上文谈到,隔离级别是为了在并发的情况下读取数据的正确性。那么现在有两个事务,几乎同时走到了步骤1查询余票(没有显式使用锁是快照度),都还没有走到步骤2。大家查询出的余票都是1,数据正确的。当事务1提交,余票变成0。当事物2提交(没有使用乐观锁),余票变成-1,出问题了,一张票卖了两次。</p><p>所以,在这种情况下,我们需要显式使用到锁或者其他方式解决业务并发的问题。隔离级别没法帮我们解决这种并发问题。</p><h4 id="MVCC-疑问"><a href="#MVCC-疑问" class="headerlink" title="MVCC 疑问"></a>MVCC 疑问</h4><p>既然采用了 MVCC ,那么 RC 隔离级别下读取的应该是快照,为什么没有解决不可重复读呢?因为 RC 在每次读取的时候都会重建 read view ,其他事务修改的数据在第二次读取的时重建 read view 是能读取到的,所以就出现了不可重复读问题。</p><h4 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h4><p>以上只是我个人不断的自问自答方式来解惑自己的问题,思路有点乱。但是,通过这种不断自问自答的方式刨根问底,很多问题就逐渐明朗起来了。</p><h4 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h4><ul><li><a href="https://tech.meituan.com/2014/08/20/innodb-lock.html">Innodb中的事务隔离级别和锁的关系</a></li><li><a href="http://blog.sae.sina.com.cn/archives/2127">MySQL 加锁处理分析</a></li><li><a href="https://elsef.com/2019/03/10/MySQL%E5%A6%82%E4%BD%95%E8%A7%A3%E5%86%B3%E5%90%84%E7%A7%8D%E4%B8%8D%E6%AD%A3%E5%B8%B8%E8%AF%BB/">MySQL的MVCC在各种隔离级别中发挥的作用</a></li><li><a href="https://mp.weixin.qq.com/s/bM_g6Z0K93DNFycvfJIbwQ">数据库村的旺财和小强</a></li><li><a href="http://mysql.taobao.org/monthly/2018/03/01/">数据库内核月报</a></li><li><a href="https://ningyu1.github.io/site/post/50-mysql-gap-lock/">MySQL Gap Lock问题</a></li></ul>]]></content>
<summary type="html"><p>在工作中,碰到一些问题的时候你会想,诶,这个知识点我似乎没理解透。数据库的隔离级别就是我最近有点困惑的知识点,大家试着回答以下问题:</p>
<ol>
<li><p>数据库的隔离级别究竟是为什么解决什么出题而出现的?</p>
</li>
<li><p>数据库的隔离级别是怎么</summary>
</entry>
<entry>
<title>Transactional 注解与 AOP</title>
<link href="http://ideajava.com/2019/08/29/Transactional-and-Aop/"/>
<id>http://ideajava.com/2019/08/29/Transactional-and-Aop/</id>
<published>2019-08-29T12:18:49.000Z</published>
<updated>2024-04-26T08:12:11.041Z</updated>
<content type="html"><![CDATA[<p>当我们在一个 private 方法上打上 @Transactional 注解,IDEA 会提示 <code>Methods annotated with '@Transactional' must be overridable</code>。比如下面的例子:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">@Service</span><br><span class="line">public class UserService {</span><br><span class="line"></span><br><span class="line"> public void method1(){</span><br><span class="line"> method2();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> @Transactional</span><br><span class="line"> private void method2(){</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>有时候我们想让事务的范围尽可能的小,可能就会写出这样的代码,在 method1 先做一些非事务的事情,然后在 method2 中做事务相关的事情。此时,如果我们用 IDEA 的话,应该就会出现上面的提示信息,eclipse 我没用不清楚会不会有这样的其实。为什么被 @Transactional 注解的方法必须是可重写的呢?</p><p>如果我们忽略掉这个信息,你会发现编译运行是没有问题的。在外部调用 UserService.method1() 时,如果 method2 没有发生异常的话,你会发现一切都是正常的。为什么 IDEA 要这样提示呢,真是怪了!</p><p>但是,当 method2 发生异常时,你会惊奇的发现 method2 的事务并<strong>没有回滚!</strong></p><h3 id="AOP"><a href="#AOP" class="headerlink" title="AOP"></a>AOP</h3><p>要理解事务为什么没有回滚,我们就要回顾一下 AOP 的知识。在 Spring 中默认是通过 JDK 动态代理的方式来实现 AOP 的,对于打了 @Transactional 注解的类,Spring 动态代理会生成一个代理 bean 和一个真实的目标 bean。</p><p>我们回头看看 method1 并没有打上注解,所以 method1 并不会被事务切面环绕。而 method2 是通过 method1 调用的,隐藏的调用对象是真实的目标 bean,真实的目标 bean 是没有切面逻辑的,切面逻辑都在代理 bean 上。这就是为什么事务没有回滚的原因,如下图所示:</p><p><img src="/image/transactional-aop.gif" alt="transactional-aop"></p><p>按照这样的理解,只要我们在 method1 方法上打上 @Transactional 注解,事务就能生效了,method2 的注解是多余的。此时,我们应该就能理解 IDEA 的善意提示了。因为 UserService 没有接口,所以只能通过 CGLIB 的方式来实现动态代理,而 CGLIB 是通过继承的方式来进行代理,需要对目标 bean 的方法进行重写,但是 private 修饰的方法是不能重写的,所以就会出现这样的提示。</p><p>但是,在 method1 上打注解,method2 上不打,那不是违背了我们当初让事务范围最小化的出发点了吗?一切又回到了原地。还有没有其他解决方法?</p><ol><li>把 method1 方法搬到 controller 层,嘿嘿,这个方法看起来很鸡贼,但是好像是更加合理的?</li><li>如果代理类和目标类合为一个,有注解的方法有切面环绕,没有注解的方法没有切面环绕,这样是不是就行了?AspectJ 静态代理就是这样的一门技术,在编译成 class 字节码的时候在方法周围织入切面逻辑。</li></ol><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><p>不仅仅是 @Transactional 会这样,其他的注解也是同理的,所以我们要深入理解 AOP 的原理,面对这些问题才能游刃有余。</p>]]></content>
<summary type="html"><p>当我们在一个 private 方法上打上 @Transactional 注解,IDEA 会提示 <code>Methods annotated with &#39;@Transactional&#39; must be overridable</code>。比如下面的例子:</summary>
</entry>
<entry>
<title>Spring transaction propagation</title>
<link href="http://ideajava.com/2019/08/22/Spring-transaction-propagation/"/>
<id>http://ideajava.com/2019/08/22/Spring-transaction-propagation/</id>
<published>2019-08-22T06:06:55.000Z</published>
<updated>2024-04-26T08:12:11.028Z</updated>
<content type="html"><![CDATA[<p>有些简单的知识点理解的比较含糊,在使用的时候总是会卡壳,Spring 事务的传播行为就是一个例子。今天再次复习一下。</p><p>事务的传播行为指的是两个方法调用之间事务怎么传播的问题。比如,方法A 有事务,调用的方法B也有事务,那么方法B 的事务是直接加入方法A 的事务还是让方法A 的事务挂起呢?当然,Spring 的事务传播行为不仅仅只有这两种情况。</p><h3 id="事务传播属性"><a href="#事务传播属性" class="headerlink" title="事务传播属性"></a>事务传播属性</h3><ul><li><strong>PROPAGATION_REQUIRED:</strong> 如果方法A没有事务,就新建一个事务;如果有,就加入当前事务。这是 Spring 提供的默认事务传播行为。</li><li><strong>PROPAGATION_SUPPORTS:</strong> 如果方法A没有事务,就以非事务方式执行;如果有,就使用当前事务。</li><li><strong>PROPAGATION_MANDATORY:</strong> 如果方法A没有事务,就抛出异常;如果有,就使用当前事务。</li><li><strong>PROPAGATION_REQUIRES_NEW:</strong> 如果方法A没有事务,就新建一个事务;如果有,就将当前事务挂起。</li><li><strong>PROPAGATION_NOT_SUPPORTED:</strong> 如果方法A没有事务,就以非事务方式执行;如果有,就将当前事务挂起。</li><li><strong>PROPAGATION_NEVER:</strong> 如果方法A没有事务,就以非事务方式执行;如果有,就抛出异常。</li><li><strong>PROPAGATION_NESTED:</strong> 如果方法A没有事务,就新建一个事务;如果有,就作为当前事务的嵌套事务。</li></ul><p>前六个应该都很好理解,就是最后一个传播行为可能会比较难理解,很容易和 PROPAGATION_REQUIRES_NEW 混淆,下面我们来分析一下这两种行为的区别。</p><h3 id="PROPAGATION-NESTED-与-PROPAGATION-REQUIRES-NEW-的区别"><a href="#PROPAGATION-NESTED-与-PROPAGATION-REQUIRES-NEW-的区别" class="headerlink" title="PROPAGATION_NESTED 与 PROPAGATION_REQUIRES_NEW 的区别"></a>PROPAGATION_NESTED 与 PROPAGATION_REQUIRES_NEW 的区别</h3><p>PROPAGATION_REQUIRES_NEW 是新开一个事务,是独立的,不会受外部事务的影响。当新开的事务开始执行时,外部事务会被挂起,内部事务结束了,外部事务继续执行。<br><img src="/image/transaction-propagation-1.png" alt="PROPAGATION_REQUIRES_NEW"></p><p>PROPAGATION_NESTED 是一个 “嵌套的” 事务,它是已经存在事务的一个真正的子事务。嵌套事务开始执行时,它将取得一个 savepoint。如果这个嵌套事务失败,我们将回滚到此 savepoint。本质上,外部事务和嵌套事务属于同一个物理事务,使用保存点实现嵌套事务。嵌套事务和它的父事务是相依的,它的提交要和它的父事务一起。也就是说,如果父事务最后回滚,它也要回滚。如果子事务回滚或提交,不会导致父事务回滚或提交,但父事务回滚将导致子事务回滚。<br><img src="/image/transaction-propagation-2.png" alt="PROPAGATION_NESTED"></p><h3 id="PROPAGATION-NESTED-用法示例"><a href="#PROPAGATION-NESTED-用法示例" class="headerlink" title="PROPAGATION_NESTED 用法示例"></a>PROPAGATION_NESTED 用法示例</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">ServiceA { </span><br><span class="line"> </span><br><span class="line"> /** </span><br><span class="line"> * 事务属性配置为 PROPAGATION_REQUIRED </span><br><span class="line"> */ </span><br><span class="line"> void methodA() { </span><br><span class="line"> try { </span><br><span class="line"> ServiceB.methodB(); </span><br><span class="line"> } catch (SomeException) { </span><br><span class="line"> // 执行其他业务, 如 ServiceC.methodC(); </span><br><span class="line"> } </span><br><span class="line"> } </span><br><span class="line"> </span><br><span class="line">} </span><br></pre></td></tr></table></figure><p>这种方式也是嵌套事务最有价值的地方,它起到了分支执行的效果, 如果 ServiceB.methodB 失败, 那么执行 ServiceC.methodC(), 而 ServiceB.methodB 已经回滚到它执行之前的 SavePoint, 所以不会产生脏数据(相当于此方法从未执行过), 这种特性可以用在某些特殊的业务中, 而 PROPAGATION_REQUIRED 和 PROPAGATION_REQUIRES_NEW 都没有办法做到这一点。</p>]]></content>
<summary type="html"><p>有些简单的知识点理解的比较含糊,在使用的时候总是会卡壳,Spring 事务的传播行为就是一个例子。今天再次复习一下。</p>
<p>事务的传播行为指的是两个方法调用之间事务怎么传播的问题。比如,方法A 有事务,调用的方法B也有事务,那么方法B 的事务是直接加入方法A 的事务</summary>
</entry>
<entry>
<title>circuit-breaker</title>
<link href="http://ideajava.com/2019/08/08/circuit-breaker/"/>
<id>http://ideajava.com/2019/08/08/circuit-breaker/</id>
<published>2019-08-08T08:17:29.000Z</published>
<updated>2024-04-26T08:12:11.047Z</updated>
<content type="html"><![CDATA[<p>在分布式系统中,对远程服务的调用很可能会<strong>临时性</strong>失败(如网络连接超时或资源过载等)。这些故障通常会在短时间内自我修复,通常我们在搭建一个可靠的分布式系统时,会通过<strong>重试</strong>策略来应对这些问题。</p><p>但是,也有可能会遇到一些意外事件导致的故障,这种故障可能会需要比较长的时间才能修复。在这种情况下,应用程序重试多少次都是失败的,而且如果此时并发很大,这些毫无意义的重试很有可能把整个服务拖垮。所以,在这种情况下,应该是让应用程序快速认识到操作已失败,并相应地处理故障。</p><h3 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h3><p><strong>断路器</strong>模式可以防止应用程序重复尝试执行很可能会失败的操作。在发生故障期间,让它快速失败进入兜底逻辑,不用浪费 CPU 周期。 断路器模式还可让应用程序检测故障是否已经解决。 如果问题已被修复,应用程序便可以尝试调用操作。</p><blockquote><p>断路器模式的目的与重试模式不同。 重试模式在预期操作将成功的情况下让应用程序重试操作。 断路器模式则防止应用程序执行很可能失败的操作。 应用程序可以使用重试模式通过断路器调用操作,来组合这两种模式。 但重试逻辑应该对断路器返回的任何异常保持敏感,并且在断路器指示故障为非临时性的情况下放弃重试尝试。</p></blockquote><p>针对可能失败的操作,断路器充当其代理。代理监控最近发生失败的次数,并使用此信息来决定是执行正常的逻辑还是兜底逻辑或者是直接返回异常。</p><h3 id="断路器状态机"><a href="#断路器状态机" class="headerlink" title="断路器状态机"></a>断路器状态机</h3><ul><li><p><strong>关闭:</strong>将应用程序的请求路由到正常逻辑。代理维护最近失败次数的计数,如果对操作的调用不成功,代理将递增此计数。 如果在给定时间段内最近失败次数超过指定的阈值,则代理将置于<strong>打开</strong>状态。 此时,代理会启动超时计时器,并且当此计时器过期时,代理将置于<strong>半开</strong>状态。</p></li><li><p><strong>打开:</strong>来自应用程序的请求立即失败,并为应用程序返回异常或者执行兜底逻辑。</p></li><li><p><strong>半开:</strong>允许数量有限的来自应用程序的请求通过并调用操作。 如果这些请求成功,则假定先前导致失败的问题已被修复,并且断路器将切换到<strong>关闭</strong>状态(失败计数器重置)。 如果有任何请求失败,则断路器将假定故障仍然存在,因此它会恢复到<strong>打开</strong>状态,并重新启动超时计时器,再给系统一段时间来从故障中恢复。</p></li></ul><blockquote><p>半开状态对于防止恢复服务突然被大量请求压垮很有用。 在服务恢复的期间,能够支持的请求数量有限,如果突然有大量的请求可能导致服务超时或再次失败。</p></blockquote><p><img src="/image/circuit-breaker-diagram.png" alt="断路器状态机"></p><p>在图中,<strong>关闭</strong>状态所使用的失败计数器是<strong>基于时间</strong>的。 它会定期自动重置。 这有助于防止断路器在遇到偶然失败时进入<strong>打开</strong>状态。 仅当在指定间隔期间内发生指定数量的失败时,才会达到将断路器跳闸到<strong>打开</strong>状态的故障阈值。 <strong>半开</strong>状态使用的计数器记录成功调用操作的次数。 在指定数量的连续操作调用成功后,断路器将恢复到<strong>关闭</strong>状态。 如果任何调用失败,断路器会立即进入<strong>打开</strong>状态,成功计数器会在下次进入<strong>半开</strong>状态时重置。</p><p>断路器模式在系统从故障中恢复时提供稳定性,并将对性能的影响降至最低。 它可以通过快速拒绝很可能失败的操作的请求(而非等待操作超时或永不返回)来帮助维持系统的响应时间。 如果断路器在每次改变状态时引发事件,则该信息可以用于监视由断路器保护的系统部分的运行状况,或者当断路器跳闸到打开状态时,对管理员发出警报。</p><p>该模式是可自定义的,并且可以根据可能的故障类型进行调整。 例如,可以向断路器应用可递增的超时计时器。 最开始可以将断路器置于打开状态几秒钟,如果故障未得到解决,则将超时增加到几分钟,以此类推。 在某些情况下,与其通过打开状态返回失败并引发异常,返回对应用程序来说有意义的默认值实则更加有用。</p>]]></content>
<summary type="html"><p>在分布式系统中,对远程服务的调用很可能会<strong>临时性</strong>失败(如网络连接超时或资源过载等)。这些故障通常会在短时间内自我修复,通常我们在搭建一个可靠的分布式系统时,会通过<strong>重试</strong>策略来应对这些问题。</p>
<p>但是,</summary>
</entry>
</feed>