php 缓存设计

本文来自网易云社区

1.前言&基本介绍

    在原始的系统架构中,我们都由程序直接连接DB,随着业务的进一步开展,DB的压力越来越大,为了缓解DB的这一压力,我们引入了缓存,在程序连接DB中加入缓存层,

从而减轻数据库压力,而且缓存一般存在于内存中,相比于存在硬盘中的DB在读取速度上绝对是比DB高几个等级。下面我们来简单聊聊关于缓存几个东西

  

<?php
$cahetime=2;//设置过期时间
$cahefile=”cahe.txt”;//读写文本

很多过度设计(overengineering)借着柔性设计的名义而自认为是正当的。但是,过多的抽象层和间接设计常常成为项目的绊脚石。看一下真正为用户带来强大功能的软件设计,你会发现他们通常有一些非常简单的部分。简单并不容易做到。

                                                                 
                                                 —来自 Eric
Evans《领域驱动设计》    

1、主动缓存(适用于更新可能小的情况,如一些设置或者配置)

从数据模型来看,种草社区 = 内容 + 关系 + 计数。在业务上,具体展开就是,

2.缓存的优缺点

    缓存的优点就是“快”,一个快字基本能概括了。如上文说的加速读写,分流对数据库的压力,归根结底就是对快字的应用及其本身,缺点主要是如下三点:

      1.数据不一致性:DB的数据与缓存中的数据不一致

      2.开发成本:需要同时处理缓存层跟DB层的逻辑,增加了开发成本

      3.维护成本:例如需要对缓存层进行一个监控,增加了运维的成本

 

if(file_exists($cahefile) && time()-$cahetime<
filemtime($cahefile)){
 
  echo file_get_contents($cahefile);
 }
 else {
  file_put_contents($cahefile,date(“y-m-d H:i:s”,time()));
 }
?>

 上面的引文当然和正文无关,对领域驱动也是了解甚少。偶然读到的,感觉挺有道理,就装B引用一下,下面开始正文。

写场景:先将数据写入到数据库,写入成功后立即把数据同步到缓存。

或者写入到数据库后,把之前的缓存失效,下次请求的时候,加入到缓存中

内容:即提问、回答、心得、用户、消息

3.缓存更新策略

     在上面中我们说到数据不一致性,一般来说缓存也是需要有生命周期的,需要被更新或者删除,这样才能保持缓存的可控性,在缓存更新中有如下三点:

      1.LR(F)U/FIFO算法删除:简单来说就是按照队列的形式对不常用的缓存进行删除,链表的形式来实现,具体可点这里

 

      2.超时删除:在设置缓存的时候可以设置过期时间,在时间到期之后自动删除。在使用这个的时候,最好还是确保缓存数据跟数据库数据不一致的时候业务能容忍,还是存在一致性的问题

 

      3.主动更新:应用于对数据一致性要求高的,但最好还是需要保证更新的准确性。假如对实时性要求不高的,还是根据超时删除吧

 

如果在系统中使用过缓存,肯定会意识到有“缓存键”这么一个概念,不管是memcached还是redis都是以字符串作为缓存键的。我要说的这个缓存键设计是在我们的系统中以什么样的方式得到这个字符串。

2、直写(异步的方式)

关系:即提问->回答, 用户->回答, 用户->消息

4.缓存粒度

    假设一张用户表有20个字段,那是否需要将全部字段都放到缓存中?这就涉及到一个粒度的问题!数据字段放少了,就会出现了不通用的问题;数据字段放多了,空间占用也多,序列化跟反序

列化消耗的性能更多了。在粒度这个问题上还是需要根据通用性,代码维护,性能跟空间占用这几点上进行考虑,
简单来说就是靠经验了

 

可能有些人会说,直接以字符串作为缓存键不就可以了吗?直接用字符串肯定是可以的,但是维护性不太好,缓存键可能遍布整个系统,就算在一个地方维护所有的键,使用者也可以随意传参,比如:

直接把Cache假装当成数据库,读写都针对Cache,然后Cache负责后面的数据同步问题,
以同步的方式 先写入数据库,写入成功后写入redis。
如果写入数据库一直没成功,采用日志记录的方式,单开个任务队列,读取日志,主动写入数据库并写入redis缓存

计数:即点赞数、粉丝数、关注数、回答数等各种计数

5.缓存穿透

      缓存穿透指的是查询一个不存在的数据,DB跟缓存都不会命中的数据。这样的话每次查询都会到DB层中查询,DB层负载加大还有可能造成死机,这样缓存就失去了保护DB层的意义。出现这种情况有两种:1.攻击,爬虫的大量请求;2.业务自身有问题。现在基本流行的解决方案有以下两种:

       5.1
缓存空对象,当DB层也查不到数据的时候,缓存一个null值进缓存,这样下一次的话就直接从缓存中读取,保护了后端。不过这种带来的后果是缓存了更多的键,需要更多的空间,而且不可控性增加

 

      5.2布隆过滤器拦截,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。这样在查询缓存之前先去过滤器中查询缓存是否有存在该key。不过这个适合于数据量固定,实时性低的应用中,因为要维护这一个过滤器。

 

    class StringCacheKeys
    {
        public static readonly string SystemName = "SystemName";
        public static readonly string NewsDetails = "NewsDetails_{0}";
    }

    class AppStringCache
    {
        public static object GetValue(string key)
        {
            return null;
        }

        public static void Invoke()
        {
            GetValue(StringCacheKeys.SystemName);
            GetValue(string.Format(StringCacheKeys.NewsDetails, 23));
            GetValue("sbadsfsdf");
        }
    }

4、其他想法:(异步的方式)

如何高效处理这几个主要元素,决定了社区系统的用户体验和服务容量。

6.雪崩优化

    指的是原先的缓存层承载了大量的请求,有效的保护了DB层,但是假如缓存层炸了,那所有的请求都直接穿透到DB层,会容易造成DB层也炸了。就这个问题一直没有一个很完美的解决方案,可以从下列两个方面进行思考:

      6.1.保证缓存层的高可用(HA),例如redis的Sentinel跟Cluster都实现了高可用(在windows10下跑这个sentinel,偶尔会出现节点挂了但是sentinel没反应过来的情况,还是linux稳定一点)

 

      6.2.提前演练,这个类似与实验设计,模拟某一层挂了的处理情况

 

如上代码,GetValue方法是使用缓存的方法,参数按我们假设用string类型,在Invoke方法里,可以传入任何字符串,虽然保证了灵活性,但失去了规范。

写入的时候,写入到redis,然后使用任务分发的方式,更新数据库,但是也需要和2一样,做数据库一致保证

最初项目为了尽快上线,这些数据都是直接到db里查询,前期访问量小,没什么关系。但到了后面,一旦访问量放大,db的资源瓶颈就会凸显。后来的压测结果也的确反应了这一点,在访问量稍微增长,数据库qps上升时,ddb的响应时间明显变长,平均响应时间有40~50ms。因此急需对这些数据进行缓存,以抵挡直接访问db的大部分流量。

7.总结

  最后用Xmind总结一下:

  图片 1

 

 

出处:

也许有人会用枚举来作为缓存键,单独使用枚举,肯定是很规范的,但是灵活性就不行了,很多时候缓存键都需要额外的具体参数填充才行,比如上面的NewsDetails_{0},我们期望根据新闻编号来缓存新闻,所以使用枚举的话,必定要借助其他的手段才能实现灵活性,比如特性(Attribute):

5、京东做秒杀系统的做法:(异步的方式)

实现这些数据的缓存做法很简单,但若是要把他做好,且各个业务模型能高效率的接入使用,则需要好好考量下。

    [AttributeUsage(AttributeTargets.Field)]
    class EnumCacheKeyDescriptorAttribute : Attribute
    {
        public string Key { get; set; }
        public EnumCacheKeyDescriptorAttribute(string key)
        {
            Key = key;
        }
    }
    enum EnumCacheKey
    {
        [EnumCacheKeyDescriptor("SystemName")]
        SystemName,

        [EnumCacheKeyDescriptor("NewsDetails_{0}")]
        NewsDetails,
    }
    class AppEnumCacheKey
    {

        public static object GetValue(EnumCacheKey key)
        {
            return null;
        }
        public static object GetValue(EnumCacheKey key, params object[] args)
        {
            var format = ""; //取出EnumCacheKeyDescriptor.Key;
            var realKey = string.Format(format, args);
            return null;
        }
    }
直接对redis做操作,同时会记录redis的操作日志,然后另外的work服务主动读取日志,同步到数据库

内容缓存,这里特指DAO缓存,以数据库主键为查询key,数据库行记录为value。

虽然可以解决问题,但是现在使用缓存的接口已经是两个了,一个没有附加参数,一个有附加参数,感觉还是不好。

个人博客:yulibaozi.com

DAO缓存的实现有一些开源框架可以直接拿来用,如spring->缓存key的设计

所以还是求助于类,求助于面向对象:

缓存key需要包含哪些元素?先来列举下之前遇到过的问题:

    public class CacheKey
    {
        TimeSpan _expires;
        string _key;

        public CacheKey(string key, TimeSpan expires)
        {
            _key = key;
            _expires = expires;
        }

        public TimeSpan GetExpires()
        {
            return _expires;
        }

        public virtual string GetKey()
        {
            if (_key.IndexOf("{0}") >= 0)
            {
                throw new Exception(_key + "需要额外参数,请调用BuildWithParams设置");
            }
            return _key;
        }


        public CacheKey BuildWithParams(params object[] args)
        {
            if (args.Length == 0)
            {
                throw new Exception("如果没有参数,请不要调用BuildWithParams");
            }
            var m = new ParamsCacheKey(_key, _expires, args);
            return m;
        }

        class ParamsCacheKey : CacheKey
        {
            object[] _args;

            public ParamsCacheKey(string key, TimeSpan expires, object[] args) : base(key, expires)
            {
                _args = args;
            }

            public override string GetKey()
            {
                return string.Format(_key, _args);
            }
        }
    }

线上环境和预发环境共用一套缓存,测试时修改预发布环境缓存会有风险

如此这般的设计一番,是否满足了我们需求呢?第一,使用缓存的接口统一为CacheKey,第二,如果需要参数,在使用的时候需要调用一下BuildWithParams方法,该方法生产一个CacheKey的不公开子类ParamsCacheKey并返回,这个ParamsCacheKey负责参数的处理。代码中还有两处抛出异常的代码,异常应该就是在这种情况下使用的吧!我们订制了规则而调用者不按照规则使用,当然要回复以异常了。我们可以像上面一样定义一个CacheKeys来统一维护缓存键:

缓存Value变更,比如缓存对象增加一个业务相关的字段,新老缓存可能同时存在,无法做到无缝发布,对业务无影响

    public static class CacheKeys
    {
        public static CacheKey NameCacheKey = new CacheKey("Name", TimeSpan.FromHours(1));

        public static CacheKey NewsCacheKey = new CacheKey("News_{0}", TimeSpan.FromHours(1));

    }

key前缀分布较随意,在代码里没有一个集中管理的地方,不同的业务有可能会冲突

CacheKey到此结束!

批量删除或迁移缓存

那么有参数的缓存键和无参数的缓存键到底有什么区别呢?不知道大家在思考这个问题的时候能想到什么,我当时是用这个问题驱动我的思维的。之后还想到的两个相关的概念:

解决上述问题,key就需要包含:

第一个是装饰器模式(允许向一个现有的对象添加新的功能,同时又不改变其结构)。我们用装饰器模式可以这样实现:

环境信息,隔离环境,防止相互影响

    class ThinkDecoration
    {
        abstract class CacheKey
        {
            public abstract string GetKey();
        }

        class StringKey : CacheKey
        {
            string _key;
            public StringKey(string key)
            {
                _key = key;
            }


            public override string GetKey()
            {
                return _key;
            }
        }

        class ParamsKey : CacheKey
        {
            CacheKey _cacheKey;
            object[] _args;

            public ParamsKey(CacheKey cacheKey, params object[] args)
            {
                _cacheKey = cacheKey;
                _args = args;
            }

            public override string GetKey()
            {
                var format = _cacheKey.GetKey();
                return string.Format(format, _args);
            }
        }


        public static void RunTest()
        {
            var key1 = new StringKey("SystemName");
            Console.WriteLine(key1.GetKey());

            key1 = new StringKey("NewsDetails_{0}");
            var key2 = new ParamsKey(key1, 23);
            Console.WriteLine(key2.GetKey());
        }
    }

版本,增加数据库字段时,可以修改版本,使业务读不到缓存从而强制刷新缓存

第二个是Python里的偏函数概念(其实很简单,就是设置一个函数的部分参数的默认值,生成新的函数),用C#简单表示一下如下:

集中的前缀管理,简单实现就是一个枚举常量,所有业务DAO前缀定义放在一起

    /// <summary>
    /// 通过设定参数的默认值,可以降低函数调用的难度
    /// </summary>
    class ThinkPartialFunction
    {
        static int Multiply(int x, int y)
        {
            return x * y;
        }

        static int MultiplyBy2(int x)
        {
            return Multiply(x, 2);
        }


        static Func<int, int> BuildMultiplyBy(int y)
        {
            return (x) => Multiply(x, y);
        }

        //python  functools.partial(Multiply,y=2)
        static Func<int, int> BuildPartial(Func<int, int, int> fun, int y)
        {
            return (x) => fun(x, y);
        }
    }

缓存未命中如何处理

回过头来再看CacheKey,应该就是装饰器模式的一种变种应用吧。但是设计的时候我可没想到什么装饰器,对设计模式也并不熟识。列出这两点,也是方便大家理解CacheKey的设计。

缓存miss,需要知道是数据真的不存在,还是仅仅缓存过期了。有些黑客可能会恶意构造数据,导致缓存无限击穿。所以需要设计一个标识不存在的对象,从缓存里取出数据时做下判断,如果是特定的空对象,则不需要再去db获取了。

 

缓存未命中时读数据库,如果是单条数据,则同步设置到缓存,如果是多条,则异步设置到缓存。

关系数据通常用于列表场景,批量取符合条件的数据,然后按指定字段排序,分页展示。

这块比较难处理的就是过滤+排序+分页。业务体量小时可以不使用缓存,建立专门的索引表,把需要作为过滤条件的字段包含到索引表里,利用数据库去处理排序、过滤。然而访问量大了之后,数据库就不适合干这个事情了。为了解决这类问题,种草社区实现了一套基于redis
sorted set的通用关系缓存API。大致的接口如下:

/** * 以索引为边界批量获取有序集合中的数据 * * @param keys 键名列表 * @param begin 偏移量开始 * @param end 偏移量结束 * @param orderType 排序类型 * @param relationCacheFilter 过滤器 * @return */Map<K, Set<V>> multiGetByIndex(final List<K> keys, final long begin, final long end, final OrderType orderType, RelationCacheFilter<V> relationCacheFilter); /** * 以分数为边界批量获取有序集合中的数据 * * @param keys 键名列表 * @param min 最小分数 * @param max 最大分数 * @param offset 偏移量 * @param limit 条数 * @param orderType 排序类型 * @param relationCacheFilter 过滤器 * @return */Map<K, Set<V>> multiGetByScore(final List<K> keys, final double min, final double max, final long offset, final long limit, final OrderType orderType, RelationCacheFilter<V> relationCacheFilter); /** * 构建缓存key * * @param k 键名 * @return 缓存key */String buildCacheKey;/** * 返回分区 * * @return */String getRegion();/** * 获取过期时间,单位秒 */Long getExpireSeconds();/** * 获取全量初始化任务线程 * * @return */RelationCacheInitRunnable getCacheInitRunnable;

实现要点:

Multiget通过redis
pipeline实现,节省网络开销,但使用时需要注意数量限制,毕竟是批量操作

数据过滤,业务方提供一个回调函数,回调函数里可实现复杂的业务逻辑

数据分页,提供两种方式,基于偏移量和基于score分值,以满足比较常见的场景,如取前n条心得,取时间段范围内的limit条数据。

缓存未命中,分两种情况处理:批量获取,异步初始化;单条获取,同步初始化;业务方提供缓存初始化线程。单条处理同步初始化是考虑到如个人主页可能会刷不到数据,体验较差。而批量获取异步初始化,某一个用户的内容没拉取到,关系不大。真是比较重要的场景,可考虑定时刷缓存。

计数主要面临的问题有几点:

高频率的读写,如何解决性能问题

有限的内存存储,缓存过期初始化导致db压力大问题

高并发更新及系统故障带来的数据一致性问题

种草社区的计数缓存基于redis,数据结构上主要用到了:

普通的string/value,存储单个计数

hash表,存储业务上有关联的一组计数,便于批量读取

大致的数据流如下图:

图片 2

之前专门写过一遍文章,详细可点击此处查看。

网易云大礼包:

本文来自网易云社区,经作者刘魏威授权发布

相关文章: 演讲实录!谷得技术总监陈镇洪教你打造游戏研发流水线
【RabbitMQ学习记录】-消息队列存储机制源码分析 知物由学 |
这些企业大佬如何看待2018年的安全形势?

You may also like...

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图