背景
当今 IT 界正处于移动互联浪潮中,涌现出一批批优秀的门户网站和电商平台。在巨大的利润驱动下,这些公司都全力打造各自的系统以适应互联网市场发展的需要,而且在此过程中各个系统还不停地接受着亿万网民的检验。经历过千锤百炼后,那些“名门大厂”都纷纷总结出“高可用,高可靠,高并发下低延迟”的优秀实践。
而南航电商营销平台这种带有国企背景和传统行业特色的电商系统在这股浪潮的引领之下,也逐步向这些“大厂”学习,结合自身的实际情况,在“高可用,高可靠,高并发下低延迟”的系统优化之路上展开一番游历探索,期间也游览了不少大坑暗沟。
我们商务移动团队作为部门里先锋部队,在打造一套以 Redis 为基础的缓存系统(取名为“黑曜石”)用于支撑南航电商营销平台,帮助公司实现“南航 e 行”战略目标的过程中对 Redis 环境中的坑涧丘壑展开了一番游历。
初步思考
2015 年是移动电商的“井喷式”发展年份,南航的官方 APP 承势而起,不过事实印证了那句话——“理想很丰满,现实很骨感”。那个时候 APP 的后台架构是按传统的企业内部应用思路搭建的,系统的研发思路是只管实现功能,不注重接口性能。
让人纠结的地方比较多,一时三刻难以对其进行全面优化改造,但上级下达的 KPI 要在短期内完成,必须要在短时间内提升一些关键接口的性能,我们第一时间想到的而且最好开刀的莫过于类似航班动态和机票信息等查询类功能接口,因为只要加上缓存,性能马上能大幅度提升。
基于这方面考虑,我们当时认为第一要务就是要搭建一套能面向互联网的缓存系统,经过几番选型后,目光开始集中到 Redis-Cluster 上。
深入探索
我们在探索期间一边翻资料,一边在测试环境搭建了一套 Redis-Cluster,经过一段时间折腾后,在 2016 年 3 月份正式投产使用。
在投产初期信心不大,所以找了小接口来尝试,缓存的数据量小(小于 1KB),结构简单,基本都是一些系统配置信息,如功能开关,APP 版本信息等,Redis-Cluster 对此表示毫无压力。
紧接着,在 16 年 3 月底,我们开始把 Redis-Cluster 投放到正规战场上,比如缓存机票信息,航班动态更新。这个接口数据量比之前的大(50-100KB),结构比较复杂,而且是我们关键功能的核心接口。使用 Redis-Cluster 缓存机票信息以及优化了接口的通讯协议后,对查票接口加速效果十分显著,接口的响应时间从 7-8 秒降到一百多毫秒,再配合 IOS 和安卓端的原生页面渲染方式优化后,实现机票信息"秒出",这标志着南航系统开始踏进“秒极俱乐部”的门槛。在一次跟全美航空的交流中,机票查询的速度把他们吓了一跳。(当时全美 app 查一下机票信息需要 8-9 秒)
应对大流量
我们解决在常规服务状态下的核心功能查询类接口的返回缓慢的问题,但这仅仅是开始,因为移动电商最大的特色就是搞促销活动,像秒杀、抽奖、派券等等,这些活动都会引发瞬时的访问高峰,访问量往往是常规服务状态下的十倍或以上,南航自 2015 年 10 月 28 日搞了第一次会员日活动后,往后的每月 28 日都会搞一次,每次的零点峰值都会对我们系统造成毁灭性打击,其实这就好像一个没穿衣服的人在冰天雪地中行走一样,所以几乎在完成接口提速的同一时间,我们用 Redis-Cluster 做了件“棉袄”让系统“穿”上——把所有流量转嫁到 Redis-Cluster 上。
利用 Redis 的单线程原子性管理访问许可证池,许可证池的大小根据活动接口的性能灵活调整。只有得到许可证的请求才能访问相关接口,当接口返回后把许可证释放回许可证池中,而得不到许可证的请求则进入 Redis blpop 的等待队列中。 这项措施结合 Nginx-Lua 的服务升降级和限流熔断机制(主要保护那些写操作功能,比如下单),确保了南航营销平台在往后的会员日或其他大促活动期间承受千万级的访问流量时仍能平稳地提供服务。
通过这种办法,在 IT 团队规模远远不如那些有名气的电商公司同类系统研发团队的情况下,经过一个月的改造,我们把一个每次都是躺着过零点高峰的系统,变成基本上能安安稳稳地站着过零点高峰的系统。
全天候服务
由于当时应用的问题较多,单链路基本难以保证 7*24 小时不间断服务,三头两日会跪一下,最直接的处理方式就是当监控报警时让运维帮忙重启。尽管我们有多条链路,但一旦某条链路 down 机重启,过程中肯定会影响到部分用户。我们团队的研发资源实在有限,而且那段时间全部精力放在确保会员日这种促销活动上(支撑业务部门冲 KPI),但这个问题又不能放任不管,所以采用比较省事的方式——在 16 年的 4 月初,我们把各条链路的 session 状态信息统一缓存到 Redis-Cluster 中,这样可以把个别链路的 down 机对用户的影响降到最低,另外写了简单监控接口让监控系统调用,当监控系统通过这个接口发现某条链路 down 了就调一下该链路上的重启脚本。
这样做一方面为团队争取了休息时间,不用为故障疲于奔命,减轻研发人员压力,另一方面其实也算把系统修成 7*24 不间断服务了,最重要的是能让团队有更充分的时间制定优化改造计划和方案,使得后来我们能在比较从容的情况下通过代码层面的优化和 JVM 调优等措施把应用出现的各种问题一一解决。
进一步优化
当链路能保证 7*24 小时不间断服务后,我们又回过头来优化那些会员日和其他促销活动中用到的写操作功能接口,如下订单、派优惠券和领优惠券之类,尤其优惠券相关的接口不但涉及双表信息写入,而且还带事务,并发一高数据库连接就占满。
最初只能通过限流的方式先处理,但这样做极不合理,活动期间大部分用户的感受是既派不了券又领不了券。
后来大概在 16 年 7 月改成把入库数据丢进队列里,排队入库,不过这样用户体验也不好,比如有人点了领券按钮,然后马上去券包查看,甚至马上使用时发现没券,要等上一段时间才看到刚才所领的券,原因是数据还在队列里,还没入库。
再后来我们就想能否先写缓存,读的时候也是先读缓存,这样就能满足用户需要。可是看看我们的 Redis:
- 不能支持结构化存储
- 不支持事务。
当时 mongoDB 可以支持结构化存储,而且支持 Sql 查询,并且承诺即将支持事务,然而我们的存储中间件已经有 Mysql 和 Redis,出于团队规模和技术栈的管理,我们不太希望把技术栈搞得太臃肿,因为不想降低本来就不算高的研发效率,避免出现技术实现时出现选择困难,而且就那么一两个写架构代码、封装搭建底层组件的人,维护多套技术岂不吐血。
当时有个同事提议把结构化存储转化成 k-v,再利用 key 的命名规则来模仿事务,这样完全可以做出基于 Redis 作为底层存储的内存数据库。于是我们进行了一些 pojo 结构转换和 key 标签封装,并把所有相关的 API 通过 JDBC 来封装,最终的效果不但支持 POJO 的结构化存储以及 SQL 语句操作,而且还支持事务。通过这种方式把优惠券信息先缓存到 Redis-Cluster 后再根据我们封装的”事务“持久化到 Mysql 中,这样就基本满足了各方需求。
在代码上看,在 Service 层把 POJO 持久化到数据库与缓存到 Redis 是无差别的,为此我的同事把这套实现称为内存数据库模块。
/** * @author DeanPhipray * OBSI DB 存储示例 * * */ @Transactional public int saveToRedis(String fieldName,Student student,Teacher Teacher) throws RdbException{ Row row=new Row(); try { row.setValue(fieldName, SeqFactory.getOID()); rStudentDao.insert("dual", row); row.setValue(fieldName, SeqFactory.getOID()); rStudentDao.insert("dual", row); row.setValue(fieldName, SeqFactory.getOID()); rStudentDao.insert(student); row.setValue(fieldName, SeqFactory.getOID()); rTeacherDao.insert(teacher); }catch (Exception e) { log.error(" 缓存实例失败 "); throw new RdbException("Rdb save fail",e); } return 2; } /** * @author DeanPhipray * OBSI DB query 示例 * * */ public Student query(@RequestParam("id") Long id,String tableName) throws RdbException{ Student student= null; try { String sql = "select * from "+ tableName + "where id = ?"; List params = new LinkedList<>(); params.add(id); student= (Student) rStudentDao.query(sql,params); } catch (Exception e) { log.error(" 查询实例失败 "); throw new RdbException("Rdb query fail",e); } return student; }
近期团队开始搞敏捷转型,我们跟一些敏捷顾问的交流中提到我们一直为技术栈做 keepfit 的理念,基本得到对方的认同。
踩坑经历
一号坑:僵尸连接
在一个月黑风高的上线夜,当大家都以为上线任务快完成时,突然有同事告诉我发布新包重启系统后,系统无法获取 Redis-Cluster 链接,重启过好几次还是这样,我马上检查集群状态,发现一切正常,但检查连接数时就惊奇地发现所有节点的连接数到达了上限。我们算了一下觉得很奇怪,因为接入的系统十根手指头数得完,而且每个系统的配置都是按我们制定的模板配参数,我们最大连接数才配了 200,空闲最大连接数 50,空闲最小连接数是 10,一般各个应用实例只会以 10 个连接连到 Redis-Cluster 各个节点中,怎么算都到不了连接数的上限啊。为了尽快恢复,我们先通过脚本命令在 Redis 服务器上清除连接,解去燃眉之急,不过治标不治本,幸亏每次清除完连接,客户端会自动重连,不影响服务,而连接数再次到达上限,大概要两天时间。
echo "client list" | redis-cli -c -p {port}|awk -F '=| ' '$12>3600{print $4}' | sed "s/^/client kill /g" | redis-cli -c -p {port}
填坑攻略:消除僵尸链接
在随后几天里,我们发现客户端设了最大超时,如果连接一直处于空闲状态,大概 5 分钟就会断开与服务器之间的长连接,但奇怪的是服务端不承认客户端的断连状态,一直保持该连接,结果从客户端的服务器看不到这种连接,但在 Redis 服务器上却看到大量这种连接,最终导致服务端连接数被占满,无法再创建新连接对外提供服务。为了让链接有一定的弹性,我们在客户端设置连接超时时间、连接池大小、最大空闲连接数、最小空闲连接数等。
<!-- jedis configuration starts --> <bean id="config" class="org.apache.commons.pool2.impl.GenericObjectPoolConfig"> <property name="maxTotal" value="200"></property> <property name="maxIdle" value="50"></property> <property name="minIdle" value="10"></property> <property name="maxWaitMillis" value="15000"></property> <property name="lifo" value="true"></property> <property name="blockWhenExhausted" value="true"></property> <property name="testOnBorrow" value="false"></property> <property name="testOnReturn" value="false"></property> <property name="testWhileIdle" value="true"></property> <property name="timeBetweenEvictionRunsMillis" value="30000"></property> </bean> <bean id="jedisCluster" class="com.csair.csmbp.util.JedisClusterFactory"> <property name="addressKeyPrefix" value="address" /> <property name="timeout" value="300000" /> <property name="maxRedirections" value="6" /> <property name="config" ref="config" /> </bean>
在服务端根据实际情况设置 tcp-keepalived 和 Timeout 这两个参数,其中建议 Timeout 的值跟客户端的超时时间一致。
二号坑:客户端过多
随着应用场景的逐渐增多,这套缓存系统引起了部门内很多项目组的兴趣和关注,接着就是纷纷踊跃接入,一下子诞生了很多客户端,带来的问题就是连接数配置难以统一规管,连接数暴增,结果某些系统 / 个别链路分不到连接,这一来就引出一个比较经典的场景,某大领导用我们的系统总是报错,而我们模仿操作想重现错误时,基本是正常 (让我们极度崩溃)。
填坑攻略:搭建代理层
这个问题发生时监控系统是不会报警的,因为监控系统是固定频率发送检测请求,一直固定占用着一条链路,而且此时的监控系统还没去监控集群的连接数。后来我们通过跨链路的日志分析系统检查日志时发现个别应用连不上 Redis-Cluster, 再看看 Redis 服务器上的连接数是处于爆满状态,不过绝对大部分连接是空闲状态,没数据流动的,由此就诞生了用代理把连接统一管理的想法。
接下来就搭建了一套轻量级的代理层集群统一管理 Redis-Cluster 链接,采用 Netty 框架处理各个系统 / 各条链路的客户端请求。
(点击放大图像)
在客户端和代理之间,代理模拟 Jedis 跟 Redis 之间的通讯协议,以 nio 的方式处理并发请求,在代理与 Redis-Cluster 之间采用 socket 长连接复用方式做请求转发,原本的客户端完全无需做任何代码改动就能接入代理集群。
代理集群会在 Redis-Cluster 中缓存代理集群的节点信息和刷新各个节点的健康状态,因为 Jedis 客户端会定时询问集群节点信息,而代理集群只需把 Master 节点替换为代理集群节点,并且对代理集群节点做一次平均的 Hash Slot 分片就能确保:
- 客户端请求集中连接到代理集群上
- 代理集群在动态扩展新节点时能被客户端自动发现
三号坑:内存最大值限制
起初缓存的数据比较少,一直没配最大内存限制,随着接入系统越来越多,缓存数据量不断增大,结果在某个风和日丽的白天,内存被挤爆,系统除了报 Cluster down 外,并没更清晰的报错,当时我们一脸迷惘,莫名其妙地查了 1 个多小时后才发现服务器内存被耗光了。
填坑攻略:设置最大内存限制
在服务端根据服务器资源的实际情况设置 maxmemory 的大小,这样有个好处就是当超过这个值时,Redis 会让 set 操作失败,而且有明确的异常信息返回。这个坑解决办法虽然非常简单,但极易被忽略,属于暗沟。
四号坑:aof 文件占满磁盘空间
有一天我们刚好完成了一个季度的任务,正准备享受那份难得的按时下班带来的小愉悦,说时迟,那时快,监控报警!集群中某台服务器上的所有实例停止服务,我们马上尝试重启上面的实例,但于事无补。于是我们只好按部就班,老老实实从 cpu、内存、磁盘空间、Redis 日志等等逐个检查,结果发现磁盘空间满了,AOF 一直阻塞,一个 aof 文件体积竟然有十几 G(其他正常的实例上 aof 文件才 2-3G),为了尽快恢复我们果断把其中两个从节点实例的 aof 文件删掉,然后再重启实例,然后就恢复正常了,不过当我们顺手重启这台服务器一个没有删除 aof 文件的实例后,这个实例的 aof 文件在重启后接近 1 分钟后从十几 G 变成了 2G,在此期间该实例进入僵死状体(单线程的弊端),这明显进行了 aof 重写啊。
填坑攻略:控制 aof 文件大小
此后每天执行 BGREWRITEAOF 指令脚本,监控磁盘空间,减少服务器上 Redis 的实例数并腾空一半内存,因为一台机上部署多个 Redis 实例会有个隐患,万一多个实例扎堆做 AOF 重写会导致 swap 或者 oom,导致重写失败,这种失败会不断重复,直至 aof 文件像滚雪球似的变大,最终塞满磁盘,另外重写体积较大的 aof 文件时,Redis 会进入 IO 阻塞状态,停止对外服务。
(点击放大图像)
结语和寄望
在近一年半的探索和实践过程中,我们团队一路坑坑洼洼,几经颠沛地走到现在,大体上摸索出一套“高可用,高可靠,高并发下低延迟”的缓存解决方案。
希望这套脱胎于Redis(红宝石) 的“黑曜石”系统,乘着“南航e 行”这股东风,能得到更好的持续的优化,在未来的日子里走得更稳、更远。
作者介绍
邓卓楠,2015 年6 月接手南航商务移动后台重构工作,2016 年7 月至今担任南航商务移动团队总体架构规划,主要负责后台接口优化与系统重构。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论