面试官:Kafka 如何优化内存缓冲机制造成的频繁 GC 问题?

Jusfr 原创,转载请注明来自博客园

Request 与 Response 的响应格式

Request 与 Response 都是以 长度+内容 形式描述, 见于 A Guide To The Kafka Protocol

Request 除了 Size+ApiKey+ApiVersion+CorrelationId+ClientId 这些固定字段, 额外的 RequestMessage 包含了具体请求数据;

Request => Size ApiKey ApiVersion CorrelationId ClientId RequestMessage
  Size => int32
  ApiKey => int16
  ApiVersion => int16
  CorrelationId => int32
  ClientId => string
  RequestMessage => MetadataRequest | ProduceRequest | FetchRequest | OffsetRequest | OffsetCommitRequest | OffsetFetchRequest

Response 除了 Size+CorrelationId, 额外的 ResponseMessage 包含了具体响应数据;

Response => Size CorrelationId ResponseMessage
Size => int32
CorrelationId => int32
ResponseMessage => MetadataResponse | ProduceResponse | FetchResponse | OffsetResponse | OffsetCommitResponse | OffsetFetchResponse

处理序列化与反序列化需求

使用 MemoryStream

序列化 Request 需要分配内存, 从缓冲区读取 Response 同理.

MemoryStream 是一个可靠方案, 它实现了自动扩容, 但扩容过程离不开字节拷贝, 而频繁分配不小的内存将影响性能, 近似的扩容示例代码如下:

// init
Byte[] buffer = new Byte[4096];
Int32 offset = 0; //write bytes Byte[] bytePrepareCopy = // from outside if (bytePrepareCopy > buffer.Length - offset) { Byte[] newBuffer = new Byte[buffer.Length * 2]; Array.Copy(buffer, 0, newBuffer, 0, offset); buffer = newBuffer; } Array.Copy(bytePrepareCopy, 0, buffer, offset, bytePrepareCopy.Length);

数组扩容可以参见 List 的实现, 这里只是示意, 没有处理长度为 (buffer.Length*2 - offset) < bytePrepareCopy.Length 的情况

在数组长度超4k 时,扩容成本非常高。如果约定“请求和响应不得超过4k“, 那么使用可回收(见下文相关内容)的固定长度的数组模拟 MemoryStream 的读取和写入行为, 能够达到极大的性能收益。

KafkaStreamBinary (见于 github) 内部使用 MemoryStream, KafkaFixedBinary (见于 github) 则是基于数组的实现;

使用 BufferManager

使用过 Memcached 的人很容易理解 BufferManager 的思路: 为了降低频繁开辟内存带来的开销,首先“将内存块化”, 申请者获取到“成块的内存”, 被分配出去的内存块标记为“已分配”; 与 Memcached 不同的是 BufferManager 期望申请者归还使用完后的内存块,以重新分配给其他申请操作。

System.ServiceModel.Channels.BufferManager 提供了一个可靠实现, 大致使用方式如下:

const Int32 size = 4096;
BufferManager bm = BufferManager.CreateBufferManager(maxBufferPoolSize: size * 32, maxBufferSize: size);
Byte[] buffer = bm.TakeBuffer(1024); bm.ReturnBuffer(buffer);

与手动分配内容的性能对比

const Int32 size = 4096;
BufferManager bm = BufferManager.CreateBufferManager(maxBufferPoolSize: size * 10, maxBufferSize: size);

var timer = new FunctionTimer(); timer.Push("BufferManager", () => { Byte[] buffer = bm.TakeBuffer(size); bm.ReturnBuffer(buffer); }); timer.Push("new Byte[]", () => { Byte[] buffer = new Byte[size]; }); timer.Initialize(); timer.Execute(100000).Print();

测试结果:

BufferManager
    Time Elapsed : 7ms
    CPU Cycles   : 17,055,523 Memory cost : 3,388 Gen 0 : 2 Gen 1 : 2 Gen 2 : 2 new Byte[] Time Elapsed : 42ms CPU Cycles : 113,437,539 Memory cost : 24 Gen 0 : 263 Gen 1 : 2 Gen 2 : 2 
  • 过小的内容使用没有使用 BufferManager 的必要,但BufferManager分配超过 4k 内存时性能下降明显;
  • 最优情况是申请人获取的内存块大小一致,如果设置maxBufferSize = 4k,但 TakeBuffer(Int32 bufferSize) 方法使用的参数大于 4k,测试表明性能还不如手动创建 Byte 数组;
  • mono 的实现存在线程安全的问题;

强制要求业务使用的请求不超过4k 貌似做得到,但需求更大内存的场景总是存在,比如合并消息、批量消费等,Chuye.Kafka 作为类库需要提供支持。

KafkaScalableBinary = BufferManager + Byte[][]

KafkaScalableBinary 并没有发明新东西, 在其内部维护了一个 Dictionary<int32, byte[]=""> 保存一系列 Byte数组;

初始化时并未真正分配内存, 除非开始写入;

public KafkaScalableBinary() : this(4096) { } public KafkaScalableBinary(Int32 size) { if (size <= 0) { throw new ArgumentOutOfRangeException("size"); } _lengthPerArray = size; _buffers = new Dictionary<Int32, Byte[]>(16); }

写入时先根据当前位置对数组长度取模 _position / _lengthPerArray 找到待写入数组,不存在则分配新数组;

private Byte[] GetBufferForWrite() {
    var index = (Int32)(_position / _lengthPerArray);
    Byte[] buffer;
    if (!_buffers.TryGetValue(index, out buffer)) {
        if (_lengthPerArray >= 128) { buffer = ServiceProvider.BufferManager.TakeBuffer(_lengthPerArray); } else { buffer = new Byte[_lengthPerArray]; } _buffers.Add(index, buffer); } return buffer; }

然后根据当前位置对数组长度取整 _position % _lengthPerArray 找到目标位置;由于待写入长度可能超过可使用长度,这里使用了 while 循环,一边获取和分配待写入数组, 一边将剩余字节写入其中,直至完成;

public override void WriteByte(Byte[] buffer, int offset, int count) { if (buffer == null) { throw new ArgumentNullException("buffer"); } if (buffer.Length == 0) { return; } if (buffer.Length < count) { throw new ArgumentOutOfRangeException(); } checked { var left = count; //标记剩余量 while (left > 0) { var targetBuffer = GetBufferForWrite(); //查找目标数组 var targetOffset = (Int32)(_position % _lengthPerArray); //查找目标位置 if (targetOffset == _lengthPerArray - 1) { //如果位置已经位于数组末尾, 说明位于起始位置; targetOffset = 0; } var prepareCopy = left; //准备写入剩余量 if (prepareCopy > _lengthPerArray - targetOffset) { //但数组的剩余长度可能不够,写入较小长度 prepareCopy = _lengthPerArray - targetOffset; } Array.Copy(buffer, count - left, targetBuffer, targetOffset, prepareCopy); //拷贝字节 _position += prepareCopy; //推进位置 left -= prepareCopy; //减小剩余量 if (_position > _length) { //增大总长度 _length = _position; } } } } 

读取过程类似,循环查找待读取数组和拷贝字节直到完成,不同的是分配内存的逻辑以一条异常替代;

public override Int32 ReadBytes(Byte[] buffer, int offset, int count) { if (buffer == null) { throw new ArgumentNullException("buffer"); } if (buffer.Length == 0) { return 0; } if (buffer.Length < count) { throw new ArgumentOutOfRangeException(); } checked { var prepareRead = (Int32)(Math.Min(count, _length - _position)); //计算待读取长度 var left = prepareRead; //标记剩余量 while (left > 0) { var targetBuffer = GetBufferForRead(); //查找目标数组 var targetOffset = (Int32)(_position % _lengthPerArray); //查找目标位置 var prepareCopy = left; //准备读取剩余量 if (prepareCopy > _lengthPerArray - targetOffset) { prepareCopy = _lengthPerArray - targetOffset; } Array.Copy(targetBuffer, targetOffset, buffer, prepareRead - left, prepareCopy); //但数组的剩余长度可能不够,读取较小长度 _position += prepareCopy; //推进位置 left -= prepareCopy; //减小剩余量 } return prepareRead; } } private Byte[] GetBufferForRead() { var index = (Int32)(_position / _lengthPerArray); Byte[] buffer; if (!_buffers.TryGetValue(index, out buffer)) { throw new IndexOutOfRangeException(); } return buffer; }

释放时释放内部维护的的全部字节;

public override void Dispose() { foreach (var item in _buffers) { if (_lengthPerArray >= 128) { ServiceProvider.BufferManager.ReturnBuffer(item.Value); } } _buffers.Clear(); }

写入缓冲区是对内部维护数组列表的直接操作,高度优化

public override void CopyTo(Stream destination) { foreach (var item in GetBufferAndSize()) { destination.Write(item.Key, 0, item.Value); } }

读取缓冲区时和写入行为类似

public override void ReadFrom(Stream source, int count) { var left = count; var loop = 0; do { var targetBuffer = GetBufferForWrite(); var targetOffset = (Int32)(_position % _lengthPerArray); var prepareCopy = left; if (prepareCopy > _lengthPerArray - targetOffset) { prepareCopy = _lengthPerArray - targetOffset; } var readed = source.Read(targetBuffer, targetOffset, prepareCopy); _position += readed; left -= readed; if (_position > _length) { _length = _position; } loop++; } while (left > 0); }

实际上可以从 MemoryStream 定义出 ScalableMemoryStream 再改写其行为,KafkaScalableBinary 依赖于 MemoryStream 而不是具体实现,整体就更加"设计模式"了 , 基本逻辑前文已陈述。

测试过程中发现,一来 **mono 的 BufferManager 实现存在线程安全问题*,故 Chuye.Kafka 提供了一个 ObjectPool 模式的 BufferManager 作为替代方案; 二是 KafkaScalableBinary 与 ScalableStreamBinary 的性能对比测试结果非常不稳定,但前者频繁的取横取整及字典开销必然是拖累,我会继续追踪和优化。

KafkaScalableBinary (见于 github), 序列化部分设计示意:


Jusfr 原创,转载请注明来自博客园

Jusfr 原创,转载请注明来自博客园

Request 与 Response 的响应格式

Request 与 Response 都是以 长度+内容 形式描述, 见于 A Guide To The Kafka Protocol

Request 除了 Size+ApiKey+ApiVersion+CorrelationId+ClientId 这些固定字段, 额外的 RequestMessage 包含了具体请求数据;

Request => Size ApiKey ApiVersion CorrelationId ClientId RequestMessage
  Size => int32
  ApiKey => int16
  ApiVersion => int16
  CorrelationId => int32
  ClientId => string
  RequestMessage => MetadataRequest | ProduceRequest | FetchRequest | OffsetRequest | OffsetCommitRequest | OffsetFetchRequest

Response 除了 Size+CorrelationId, 额外的 ResponseMessage 包含了具体响应数据;

Response => Size CorrelationId ResponseMessage
Size => int32
CorrelationId => int32
ResponseMessage => MetadataResponse | ProduceResponse | FetchResponse | OffsetResponse | OffsetCommitResponse | OffsetFetchResponse

处理序列化与反序列化需求

使用 MemoryStream

序列化 Request 需要分配内存, 从缓冲区读取 Response 同理.

MemoryStream 是一个可靠方案, 它实现了自动扩容, 但扩容过程离不开字节拷贝, 而频繁分配不小的内存将影响性能, 近似的扩容示例代码如下:

// init
Byte[] buffer = new Byte[4096];
Int32 offset = 0; //write bytes Byte[] bytePrepareCopy = // from outside if (bytePrepareCopy > buffer.Length - offset) { Byte[] newBuffer = new Byte[buffer.Length * 2]; Array.Copy(buffer, 0, newBuffer, 0, offset); buffer = newBuffer; } Array.Copy(bytePrepareCopy, 0, buffer, offset, bytePrepareCopy.Length);

数组扩容可以参见 List 的实现, 这里只是示意, 没有处理长度为 (buffer.Length*2 - offset) < bytePrepareCopy.Length 的情况

在数组长度超4k 时,扩容成本非常高。如果约定“请求和响应不得超过4k“, 那么使用可回收(见下文相关内容)的固定长度的数组模拟 MemoryStream 的读取和写入行为, 能够达到极大的性能收益。

KafkaStreamBinary (见于 github) 内部使用 MemoryStream, KafkaFixedBinary (见于 github) 则是基于数组的实现;

使用 BufferManager

使用过 Memcached 的人很容易理解 BufferManager 的思路: 为了降低频繁开辟内存带来的开销,首先“将内存块化”, 申请者获取到“成块的内存”, 被分配出去的内存块标记为“已分配”; 与 Memcached 不同的是 BufferManager 期望申请者归还使用完后的内存块,以重新分配给其他申请操作。

System.ServiceModel.Channels.BufferManager 提供了一个可靠实现, 大致使用方式如下:

const Int32 size = 4096;
BufferManager bm = BufferManager.CreateBufferManager(maxBufferPoolSize: size * 32, maxBufferSize: size);
Byte[] buffer = bm.TakeBuffer(1024); bm.ReturnBuffer(buffer);

与手动分配内容的性能对比

const Int32 size = 4096;
BufferManager bm = BufferManager.CreateBufferManager(maxBufferPoolSize: size * 10, maxBufferSize: size);

var timer = new FunctionTimer(); timer.Push("BufferManager", () => { Byte[] buffer = bm.TakeBuffer(size); bm.ReturnBuffer(buffer); }); timer.Push("new Byte[]", () => { Byte[] buffer = new Byte[size]; }); timer.Initialize(); timer.Execute(100000).Print();

测试结果:

BufferManager
    Time Elapsed : 7ms
    CPU Cycles   : 17,055,523 Memory cost : 3,388 Gen 0 : 2 Gen 1 : 2 Gen 2 : 2 new Byte[] Time Elapsed : 42ms CPU Cycles : 113,437,539 Memory cost : 24 Gen 0 : 263 Gen 1 : 2 Gen 2 : 2 
  • 过小的内容使用没有使用 BufferManager 的必要,但BufferManager分配超过 4k 内存时性能下降明显;
  • 最优情况是申请人获取的内存块大小一致,如果设置maxBufferSize = 4k,但 TakeBuffer(Int32 bufferSize) 方法使用的参数大于 4k,测试表明性能还不如手动创建 Byte 数组;
  • mono 的实现存在线程安全的问题;

强制要求业务使用的请求不超过4k 貌似做得到,但需求更大内存的场景总是存在,比如合并消息、批量消费等,Chuye.Kafka 作为类库需要提供支持。

KafkaScalableBinary = BufferManager + Byte[][]

KafkaScalableBinary 并没有发明新东西, 在其内部维护了一个 Dictionary<int32, byte[]=""> 保存一系列 Byte数组;

初始化时并未真正分配内存, 除非开始写入;

public KafkaScalableBinary() : this(4096) { } public KafkaScalableBinary(Int32 size) { if (size <= 0) { throw new ArgumentOutOfRangeException("size"); } _lengthPerArray = size; _buffers = new Dictionary<Int32, Byte[]>(16); }

写入时先根据当前位置对数组长度取模 _position / _lengthPerArray 找到待写入数组,不存在则分配新数组;

private Byte[] GetBufferForWrite() {
    var index = (Int32)(_position / _lengthPerArray);
    Byte[] buffer;
    if (!_buffers.TryGetValue(index, out buffer)) {
        if (_lengthPerArray >= 128) { buffer = ServiceProvider.BufferManager.TakeBuffer(_lengthPerArray); } else { buffer = new Byte[_lengthPerArray]; } _buffers.Add(index, buffer); } return buffer; }

然后根据当前位置对数组长度取整 _position % _lengthPerArray 找到目标位置;由于待写入长度可能超过可使用长度,这里使用了 while 循环,一边获取和分配待写入数组, 一边将剩余字节写入其中,直至完成;

public override void WriteByte(Byte[] buffer, int offset, int count) { if (buffer == null) { throw new ArgumentNullException("buffer"); } if (buffer.Length == 0) { return; } if (buffer.Length < count) { throw new ArgumentOutOfRangeException(); } checked { var left = count; //标记剩余量 while (left > 0) { var targetBuffer = GetBufferForWrite(); //查找目标数组 var targetOffset = (Int32)(_position % _lengthPerArray); //查找目标位置 if (targetOffset == _lengthPerArray - 1) { //如果位置已经位于数组末尾, 说明位于起始位置; targetOffset = 0; } var prepareCopy = left; //准备写入剩余量 if (prepareCopy > _lengthPerArray - targetOffset) { //但数组的剩余长度可能不够,写入较小长度 prepareCopy = _lengthPerArray - targetOffset; } Array.Copy(buffer, count - left, targetBuffer, targetOffset, prepareCopy); //拷贝字节 _position += prepareCopy; //推进位置 left -= prepareCopy; //减小剩余量 if (_position > _length) { //增大总长度 _length = _position; } } } } 

读取过程类似,循环查找待读取数组和拷贝字节直到完成,不同的是分配内存的逻辑以一条异常替代;

public override Int32 ReadBytes(Byte[] buffer, int offset, int count) { if (buffer == null) { throw new ArgumentNullException("buffer"); } if (buffer.Length == 0) { return 0; } if (buffer.Length < count) { throw new ArgumentOutOfRangeException(); } checked { var prepareRead = (Int32)(Math.Min(count, _length - _position)); //计算待读取长度 var left = prepareRead; //标记剩余量 while (left > 0) { var targetBuffer = GetBufferForRead(); //查找目标数组 var targetOffset = (Int32)(_position % _lengthPerArray); //查找目标位置 var prepareCopy = left; //准备读取剩余量 if (prepareCopy > _lengthPerArray - targetOffset) { prepareCopy = _lengthPerArray - targetOffset; } Array.Copy(targetBuffer, targetOffset, buffer, prepareRead - left, prepareCopy); //但数组的剩余长度可能不够,读取较小长度 _position += prepareCopy; //推进位置 left -= prepareCopy; //减小剩余量 } return prepareRead; } } private Byte[] GetBufferForRead() { var index = (Int32)(_position / _lengthPerArray); Byte[] buffer; if (!_buffers.TryGetValue(index, out buffer)) { throw new IndexOutOfRangeException(); } return buffer; }

释放时释放内部维护的的全部字节;

public override void Dispose() { foreach (var item in _buffers) { if (_lengthPerArray >= 128) { ServiceProvider.BufferManager.ReturnBuffer(item.Value); } } _buffers.Clear(); }

写入缓冲区是对内部维护数组列表的直接操作,高度优化

public override void CopyTo(Stream destination) { foreach (var item in GetBufferAndSize()) { destination.Write(item.Key, 0, item.Value); } }

读取缓冲区时和写入行为类似

public override void ReadFrom(Stream source, int count) { var left = count; var loop = 0; do { var targetBuffer = GetBufferForWrite(); var targetOffset = (Int32)(_position % _lengthPerArray); var prepareCopy = left; if (prepareCopy > _lengthPerArray - targetOffset) { prepareCopy = _lengthPerArray - targetOffset; } var readed = source.Read(targetBuffer, targetOffset, prepareCopy); _position += readed; left -= readed; if (_position > _length) { _length = _position; } loop++; } while (left > 0); }

实际上可以从 MemoryStream 定义出 ScalableMemoryStream 再改写其行为,KafkaScalableBinary 依赖于 MemoryStream 而不是具体实现,整体就更加"设计模式"了 , 基本逻辑前文已陈述。

测试过程中发现,一来 **mono 的 BufferManager 实现存在线程安全问题*,故 Chuye.Kafka 提供了一个 ObjectPool 模式的 BufferManager 作为替代方案; 二是 KafkaScalableBinary 与 ScalableStreamBinary 的性能对比测试结果非常不稳定,但前者频繁的取横取整及字典开销必然是拖累,我会继续追踪和优化。

KafkaScalableBinary (见于 github), 序列化部分设计示意:


Jusfr 原创,转载请注明来自博客园

猜你喜欢

转载自www.cnblogs.com/jpfss/p/11790465.html
今日推荐