原文地址 微信公众号
如下图所示是华为手机应用商店首页截图,我们看到有几个模块:精品应用,精品新游,还有截图中没有展示出来的大家都在用模块等。无论这些模块的 APP 是以多复杂的算法推荐的,其最终展示给用户看的就是 30 个 APP(一个大概数量)。
华为应用商店首页
SQL 优化
项目初期,可能并发不大,且开发时间短促,一切为了尽快上线。这时候缓存都来不及使用,那么只需要优化好 SQL 即可:
select * from tb_app where app_id in(?, ?, ?)
SQL 如上,批量查询,并且给 app_id 加上索引即可。
Redis 优化
第一版很明显无法支持高并发,别说高并发,几十万用户估计都扛不住,这时候就需要引入 Redis 缓存。由于应用商店这种类型品类,具备典型的长尾效应,即用户最多只会接触到 10% 的 APP,其他 90% 几乎很少有人访问(不止应用商店,淘宝购物,或者其他很多场景都符合长尾效应),如果 APP 的缓存不失效,那绝对是一个巨大的浪费。所以为了避免这种的浪费,每个 KEY 都会带上过期属性。那么这时候的伪代码如下:
// mget参数是app id集合
List<App> appListFromRedis = redisClient.mget(List<Integer>);
// 由于每个key都会缓存过期,所以可能会有部分app的信息无法从缓存区获取到,那么这部分app还需要从数据库中获取
List<App> appListFromDB = appMapper.getByIds(List<Integer>);
从缓存中批量取出来的有效结果和从数据库批量取出来的结果结合就是完整的结果。
谨慎的同学可能发现,这样处理就会出现缓存穿透的情况,缓存穿透可以分为两大类:
-
无效的 app id 导致缓存穿透:这类一般是非法请求,可以通过 Redis 的 bitmaps 对无效 app id 进行拦截,还有缓存一个空对象并设置过期时间等方法优化。
-
有效的 app id 导致缓存穿透:这类请求是由于缓存过期导致的,也有一些办法优化:为了避免大量 KEY 集中失效,可以设置一个 EXPRE_TIME + 随机数的过期时间值,另外可以分析总榜 TOP-N,蹿升榜 TOP-N,活动页等方式,对这些预判为热门 APP 的过期时间做特殊处理,例如监听失效自动 reload 缓存,EXPIRE_TIME 是普通 KEY 的 n 倍等。
和阿里的谢照东大神聊过阿里双 11 的缓存优化策略,他们会提前分析用户的购物车,浏览记录等信息,对那些预判可能访问量很大的商品,提前将其缓存并设置过期时间延长到双 11 以后。
需要说明的是,缓存穿透不可能完全杜绝,例如一个刚上架的 APP,这时候其信息不需要被缓存,那么第一次访问这个 APP,就会缓存穿透,从而需要从数据库中查询。但是这种小流量是在数据库能承受的范围内,并且完善的系统一般每一层都有限流机制,防止数据库被击跨。
本文不重点讲解缓存穿透的优化,所以点到为止。
缓存集群优化
然后由于业务的增长,数据库中的 APP 越来越多,即使长尾效应,单机 redis 容量还是扛不住(sentinel 架构的存储能力还是单机存储能力,只是多了几个节点的备份而已),所以迟早有一天缓存会采用 redis cluster。
细心的网友发现我在前面用到的 redis 的 mget 命令,这是 redis 单机或者 sentinel 架构才有的命令。在 redis3.x 即 redis 集群架构体系下,是没有 mget 这个命令的。那么怎么办呢?总不至于 for 循环调用 redis 的 get 命令吧?这肯定不行,性能会下降一个量级。那怎么办呢?
网上有对该场景的优化方案,例如 cachecloud 给出的优化方案如下:
image.png
图片来自于:https://github.com/sohutv/cachecloud/wiki/5.%E8%AE%BE%E8%AE%A1%E6%96%87%E6%A1%A3
然而这个方案也有缺陷,当 redis 集群的节点过多,可能导致每次 mget 请求的 30 个 app id 分布在完全不同的节点上,那么网络 IO 就会成为不可忽视的因素,这时候即使是优化后的 mget 其性能也会一般。而且品类越多,需要的 redis 节点越多,出现这种问题的概率就越大。如下图所示,当 Redis 集群中节点过多,网络 IO 也会不断增长,而只有一个 Redis 节点时,多个 KEY 的 mget 查询只需要一次网络 IO 即可:
Redis meget 网络 IO
难道没有其他办法?
更近一步
办法总会有的,以应用商店首页为例,已知首页投放的 30 个 APP,那么我们可以这样缓存数据:
key: PageNo:1
value: [{"appId":"12", "appName":"支付宝", "packageName":"com.android.alipay", "iconUrl":"https://pp.myapp.com/ma_icon/0/icon_5294_1539921151/96"},......,{"appId":"26", "appName":"微信", "packageName":"com.tecent.wechat", "iconUrl":"https://pp.myapp.com/ma_icon/0/icon_10910_1539831498/96"}]
缓存的value就是首页30个APP,即List<APP>的json字符串。
但是这样也会带来麻烦,即缓存更新的麻烦。如果要做到尽可能的实时更新,这 30 个 APP 无论哪个 APP 的属性有变更,PageNo:1 的缓存都需要更新。当然,如果产品能够接受一定的缓存更新延迟,我们可以等待缓存失效后再加载 PageNo:1 这个 KEY 的缓存信息。
总之都是 trade-off,架构师不就是根据软件和硬件的瓶颈,找出一种折中的方案吗?试想一下,如果 MySQL 单机能力秒天秒地,还要什么分库分表,还要什么 Redis,还要什么本地缓存。
本地缓存
Redis 缓存性能再优秀,还是有一次网络 IO 开销。当用户越来越多,并发越来越大,我们还需要进一步优化。这个时候我们还可以结合本地缓存进行优化(Guava 提供了操作本地缓存的 API)。本地缓存的 KEY 也是 PageNo:1,value 也是 APP 集合的 json 字符串。并且设置本地缓存失效时间不要太大,一般几秒钟即可(如此以来,用户看到过期数据的时间就越在可接受范围内)。失效后,从 Redis 缓存中重新加载数据到本地缓存。
这样一路优化下来,首页的性能杠杠的,即使有几千万甚至上亿的用户,那也是妥妥的!
文末彩蛋
还有没有可能再进一步优化,从而能够从容应对更大的流量?答案是肯定的!笔者给你推荐一款牛逼哄哄的产品:Varish--高性能的开源HTTP加速器
。以前的淘宝,现在的拼多多都使用过 varish 来应对几个亿的用户产生的流量。本文不过多介绍,毕竟是彩蛋,希望接下来有机会做更深入的介绍,当然你也可以自己去研究这个牛逼的技术啦。