最近压测登陆接口,发现处理超过1k的byte对象(我们初始化就有3k,跑起来后可以达到40k)时候,整体的tqs下降得十分厉害。从一般几千得并发掉落倒了不到一千,还有不少得错误。

经过调查这个应该是redis在处理大key时候的一个坑,每次的全量更新让单线程的redis十分缓慢,无法在规定时间内完成操作。

解决办法

对于大key,网上查了下给了两种解决方案:

1. 改成hash格式,每次更新hash的一个部分
2. 将大key改成多个小key的组合,使用multiSet,或者multiGet来完成业务。

这两种方案其实大同小异,但比较不爽的是需要对业务部分进行从新梳理。这样才可以控制每个部分的大小。改造方案其实是十分昂贵的。如果不是一开始就有考虑这个部分,其实不建议这么操作。

对于redis的key-value模型,value其实作为stream存储的话,我们可以采用一种另存整取的思路来做。比如每次我按照200Byte存一次,对于一个1KB的数据,我们需要存5条记录就可以了。相应的,在我们需要读取时候,我们把对应key所有的相关的key数据按照一定顺序还原就可以恢复成原来的对象了。

零存方案

        public async Task SegmentWrite(string key, byte[] data)
        {
            IByteBuffer bytesBuffer = Unpooled.Buffer(data.Length);
            try
            {
                // 加锁
                if (!await Lock(key))
                {
                    throw new Exception("Lock-fail " + key);
                }

                IBatch batch = DB().CreateBatch();
                bytesBuffer.WriteBytes(data);
                int j = 0;
                while (bytesBuffer.ReadableBytes > 0)
                {
                    var ReadableBytes = Math.Min(segmentLength, bytesBuffer.ReadableBytes);
                    var segKey = key + "-" + j++;
                    //await batch.StringAppendAsync(segKey, bytesBuffer.ReadBytes(ReadableBytes).Array, flags: CommandFlags.FireAndForget);
                    await batch.StringSetAsync(segKey, bytesBuffer.ReadBytes(ReadableBytes).Array, flags: CommandFlags.FireAndForget);
                }
                if (j>0)
                    batch.Execute();

            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
            finally
            {
                bytesBuffer.Release();
                data = null;
                if (!await Unlock(key))
                {
                    throw new Exception("Unlock-fail");
                }
            }
        }

整取方案

        public async Task<T> SegmentRead<T>(String key)
        {
            IByteBuffer bytesBuffer = Unpooled.Buffer(segmentLength);
            try
            {
                if (!await Lock(key))
                {
                    throw new Exception("Lock-fail " + key);
                }
                RedisResult keysResult = await DB().ExecuteAsync("keys", key + "-*");
                if (keysResult.IsNull) {
                    return default;
                }
                RedisResult[] keyList = (RedisResult[])keysResult;

                List<Task> ayncTaskList = new List<Task>();
                foreach (var _key in keyList.OrderBy(k => Encoding.Default.GetString((byte[])k)).ToArray())
                {
                    var keystr = Encoding.Default.GetString((byte[])_key);
                    bytesBuffer.WriteBytes(await DB().StringGetAsync(keystr));
                }
                return bytesBuffer.ReadableBytes > 0 ? bytesBuffer.ReadBytes(bytesBuffer.ReadableBytes).Array.Deserialize<T>() : default;
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
                return default;
            }
            finally
            {
                bytesBuffer.Release();
                if (!await Unlock(key))
                {
                    throw new Exception("Unlock-fail");
                }

            }

        }

注意事项

  1. 由于对于大key单次的写入和读取操作都被分割成了小块,所以redis的原子操作已经被破坏。所以我们每次写之前对于同一个key要加锁,写完之后再释放锁。同理,在读的时候也要检查是否有写的锁,如果某个进程正在写入同一个key,则需要等待。
  2. 等待不能无止境的持续下去,要做好保护,防止程序卡死。
  3. 对于切割出来的基本单元可以采用pipeline写入方式保证效率
  4. 读取key的时候一定要做好排序,保证结果的正确性。
  5. 不能将基本单元切割得过小,产生的key过多也会成为性能瓶颈。
  6. 减少内存拷贝

测试结果

在500B为一个基本单元情况下的测试结果。可以看到错误率明显下降,整体的tqs也上升了20%左右。

观察了下elk的做法,它也会将index拆分成5个shard,然后做一份replica,再散列在集群里面。这么做的好处是可以减少某个热key引起的请求偏移;当然也不需要再关注上层的业务逻辑了。这应该是一个比较主流的做法: