导读:对于企业级应用系统,缓存是提高系统性能的利器,特别是分布式系统环境下的缓存机制,对系统性能至关重要。本文将介绍在分布式缓存使用过程中遇到问题和经验的总结。

一、背景

1、缓存的介绍

当企业应用系统数据和用户规模达到一定的量级之后,数据库的压力会越来越大,一般都会引入缓存来优化查询性能,将业务中不易变动的数据放到缓存中以提高查询的速度。另一方面随着业务的发展系统的并发数也越来越高,这个时候一般引入负载均衡将Web服务器集群化,原有的内存缓存就会存在站点之间不同步的问题,此时就会引入分布式缓存来集中存放缓存数据,从而实现缓存数据在多个站点之间的共享,从而保持一致性。

2、关于Session

在使用缓存的时候最开始接触到的就是session了,微软提供了分布式session的解决方案,但是由于asp.net session是在http请求的时候获取全部session的数据,然后结束的时候再将session存入asp.net状态服务,而现在浏览器单个页面对资源的请求都是并发执行,这样就会是浏览器并发执行无效,所以我们在最开始的时候就弃用了session。为了解决例如用户权限这类会话级别数据的缓存,我们引入了分布式缓存,使用用户的Id作为key的一部分来隔离多个会话之间的数据,实现类似session的效果。session的过期清理会在用户关闭浏览器之后一段时间内过期,而我们的数据清理的策略这里设计有两种:一种是按照预置的过期时间进行过期,一种是在用户登录的时候会根据用户的Id先删除之前用户相关的缓存数据,然后再按需加载缓存。这里就会用到使用用户id进行模糊匹配删除的功能。

本文主要是讲述我们在分布式缓存使用过程中遇到问题和经验的总结。

二、Memcached

最开始我们使用的就是以Memcache作为分布式缓存的方案,当时我们遇到了两个问题:

  1. 缓存服务端会不稳定;
  2. 缓存客户端会偶发出现读写缓存失败的异常;

1、问题场景

  1. 客户线上同时存在1.4.4和1.5.12的版本,调查后发现去年12月份部分头部客户因为服务端不稳定所以做了一轮升级。
  2. 官方网站明确说明了不支持Windows系统的部署,而当时我们使用的是这两个版本无法追溯软件下载的源头,基本可以确定是第三方基于windows编译的版本,并且经过搜索引擎发现还并非正规的第三方编译,可能是属于个人编译的版本。
  3. 部分客户的客户端使用的是BeITMemcached版本,部分客户使用的是Enyim.Caching的版本。
  4. Enyim.Caching的版本客户端页面打开慢,长达30s,重启缓存服务器后正常,并且异常日志中存在大量的读写失败异常
  5. BeITMemcached版本客户端使用的获取所有key的方法是变通方案,其中使用到的stats cachedump不是官方支持的功能,生产用途使用风险很大。
  6. Memcached中缓存值限制为1M,部分缓存内容会大于这个限制。

2、问题归类

在收集问题之后发现还真是比较乱,那么首先我们对问题进行归类,简单来说归为下面几类:

  1. 服务端的安装包来源问题;
  2. 服务端的版本不一致;
  3. 客户端SDK不一致;
  4. 客户端SDK偶发性错误;
  5. 客户端SDK本身风险;

针对上述问题的应对方案:

  • 服务端稳定性问题:我们针对不同的版本进行稳定性测试对比,这里为了对比第三方编译安装程序问题,我们还引入了基于官方Linux版本的测试。
  • 客户端问题:直接抛弃原客户端组件BeITMemcached,使用Enyim.Caching作为客户端。同时进行基于内容大小的性能测试。

3、稳定性测试

测试方案:

  1. 100线程循环读取所有的Key;
  2. 100线程循环写入和读取,每个线程写入读取10w条数据;
  3. 多次运行后记录结果;
  4. 服务端以cmd方式启动;
  5. 运行时间大于20分钟(根据实际情况时间还可能增加);

测试结果:

  • Windows1.4.4 版本客户端偶尔出现输出“读取Memcached缓存失败”,服务端警告信息:[Warn] select: no error;
  • Windows1.5.12 版本客户端偶尔出现输出“读取Memcached缓存失败”,服务端正常;
  • Linux版本客户端偶尔出现输出“读取Memcached缓存失败”,服务端正常;
  • 基于Windows1.4.4切换本地环境和服务器环境,测试结果相同;

4、内容性测试

在官方文档中有说明,毫秒级别的响应速度是基于key-value中value数据量比较小的时候做的测试,实际我们在使用缓存中有部分业务数据的体积比较大,这里我们针对缓存大小做了一个基本的测试.下面是测试结果:




ubantu清理redis缓存 linux清理redis缓存_linux清理缓存


5、性能测试

测试方法:单线程写入入1w条数据后立马读取数据,循环多次以后计算平均值; 为了排查客户端问题还引入了nodejs的测试。下面是测试结果:


ubantu清理redis缓存 linux清理redis缓存_写入缓存策略无法更改_02


6、测试结论

经过如下分析得出了如下结论:

  1. windows1.4版本稳定性差(不可用);
  2. windows1.5版本性能比官方慢(视实际情况看是否可用);
  3. 随着内容大小基本处于线性增长趋势;
  4. 客户端偶尔出现输出“读取Memcached缓存失败”;在调查客户端问题的时候分析源代码后发现:客户端的异常是由框架代码封装以后二次抛出的,原始的异常时由于socket获取不到资源后抛出的,最终我们发现,客户端会有一个连接池,针对每一个请求都会从连接池中取出一个连接使用,而如果获取不到连接则会等待一段时间,如果等待一段时间获取不到连接就会抛出异常"Failed to obtain socket from pool"(无法从连接池获取套接字)。

从而确定客户端的两个重要参数:

  • 连接池大小:SocketPool.MaxPoolSize默认值20;
  • 连接池等待时间:SocketPool.QueueTimeout默认值100毫秒;
  • 在调整这个参数之后SocketPool.MaxPoolSize=100,SocketPool.QueueTimeout=1000毫秒之后,之后多轮测试都显示客户端稳定;

三、Memcached改进方案

1、针对测试稳定性和偶发性的问题:

我们的解决方案是:

  • 客户端参数调整成SocketPool.MaxPoolSize=100,SocketPool.QueueTimeout=1000毫秒;
  • 将所有服务端升级成1.5.2,虽然性能稍差,但是实际能满足ERP的性能要求,至少还是比数据库要快,并且减轻数据库压力;

2、针对测试中内容大小的问题:

我们按照场景分析有两类:

  1. 外部输入数据需要在多个服务器进行同步(例如excel文件导入);
  2. 内部从数据库读取的数据缓存(例如权限,菜单) ;

针对上述不同场景采取不同的策略, 对于外部输入的数据我们采取了将缓存数据压缩和分片的方案:

  • 如果缓存数据大于200K,开启数据压缩(GZip);
  • 如果压缩后的数据大于900K,开启数据分片策略;
  • 数据分片后,将原数据切分为多块数据,每块数据分别写入缓存,缓存Key使用Guid.New();
  • 将所有分片的缓存Guid合并在一起写入到主条目,使用原数据的缓存Key;
  • 读取时,做反向操作:读取主条目->解析->读取分片->合并→解压缩;

对于内部从数据库读取的缓存数据我们采取用本地缓存+Memcached校验一致性的方案:

  • 写入逻辑:缓存数据存储到内存中,将数据的Hash值存入Memcached;
  • 读取逻辑:在从内存中读取缓存后,计算数据的Hash值,与Memcached读取的Hash值比较;如果不一致,视为本地缓存无效,返回null(值类型返回默认值),如果一致,返回本地的内存数据;

3、针对模糊删除的问题:

这里涉及到一个历史问题,erp的架构在api暴露出去以后就很难收回来,因为涉及到很多产品线和定制化开发的使用,但是架构又需要向前兼容。所以采用了数据库存储key的方案:

  • 写入缓存的时候将key写入数据库;
  • 模糊删除的时候先通过like查询key然后删除;

四、Redis缓存方案

由于特性的需要我们引入了SignalR作为客户端推送框架,官方给出的分布式方案中就包含了Redis的需求,所以借助于SignalR我们引入了Redis的支持.那么回过来看在Memcached的模糊匹配方案,在redis中有按照key模糊匹配的功能,并且在原来Memcached的方案中有部分客户会出现存放key的表出现死锁导致卡顿和超时的情况,所以将缓存进行了Redis的重构。在选择Redis进行模糊删除的方案中有存在如下的方案:

  1. 客户端key模糊匹配后批量删除,StackExchange中会根据redis支持的版本选择使用keys 和scan的方式(我们采用的windows3.2的版本,所以默认采用scan的方式);
  2. 服务端keys模糊匹配后删除;
  3. 服务端scan模糊匹配后删除。由于客户端使用scan的时候会跟服务端做多次迭代获取所有keys然后再删除,而redis支持lua脚本。在服务端进行模糊匹配的时候,网上有很多说法说keys * 会阻塞服务器,导致在key过多的情况下性能较差,所以这里又增加了scan模糊匹配的方式,这里由于lua脚本定制性相关代码如下: 我们模拟5000个用户,每个用户30个key,先循环插入15w个key,使用100个线程并发根据用户id的前缀来删除删除,下面是模拟并发线程的代码:


ubantu清理redis缓存 linux清理redis缓存_写入缓存策略无法更改_03


我们模拟5000个用户,每个用户30个key,先循环插入15w个key,使用100个线程并发根据用户id的前缀来删除删除,下面是模拟并发线程的代码:


ubantu清理redis缓存 linux清理redis缓存_linux清理缓存_04


我们使用了ThreadPerTaskScheduler 作为执行计划,因为默认的并发采用的是线程池,实际并不能到达100线程的并发,而微软提供的类库ParallelExtensionsExtras的ThreadPerTaskScheduler,可以按照一个并发一个线程来执行,所以更加贴近实际并发。最终测试结果如下:


ubantu清理redis缓存 linux清理redis缓存_客户端_05


虽然按照网上的说法在百万千万key的情况下会出现阻塞,但是在我们erp的实际场景中并不会存在如此大的数据,头部客户也没有超过15万key的场景,所以我们最终选取了服务端keys删除的方式。

五、结论

按照惯例,解决了问题之后就要做一下总结:

  • 引入第三方中间件要从官方网站下载;
  • 第三方中间件要做安全性,稳定性,性能测试;
  • 客户端SDK要选取持续维护的稳定版本;
  • 客户端SDK使用时候要对配置项进行分析选取合适的配置;

然后分析下心路历程:

  1. 因为Session的串行调用禁用了Session;
  2. 禁用了Session,导致用户相关缓存需要在重新登陆的时候清除;
  3. 清除的时候需要模糊匹配,所以引入了一个魔改的Api;
  4. 因为Api的兼容性,所以需要用数据库解决问key模糊匹配问题;

最后,对于缓存的设计还有两个比较好的方案:

  • 改造Asp.net的Session实现使用分布式缓存并且修改为按需加载,实时保存即可。
  • 伪造一个SessionId存放在cookies,利用SessionId隔离缓存数据从而模拟Session,也可以实现同样效果,这样也避免了实现了Asp.net Session实现的复杂度。
  • 这里最根本原因是在最开始的时候遇到问题并没有找对解决办法而是选择绕开,并且在绕开的时候又把用户的生命周期和HTTP会话的生命周期理解错误导致了上述一些列问题。
  • 所以遇到问题尽量不要逃避,直面问题去解决是最佳途径。