PHP内缓存的使用 -- 如何更新缓存

2016/11/28 PHP

为了使程序执行速度更快,所以现有业务中大量使用了缓存机制,这里主要介绍下平时业务中用到的缓存的应用及缓存更新的策略。由于缓存涉及到的场景太多了,所以本次仅介绍书写代码时最最常用的基础知识 – 内存缓存。

一个轻量级请求中存在的缓存

一个轻量级请求中存在的缓存

PHP内缓存的应用

一般模式

cache-demo

PHP中变量级别的缓存

适用场景:对于数据量较少,获取耗时,读多写少的情况

缺点:仅在生命周期内生效,进程间无法共享


class A {
    public static $x = null;
    public static $xx = null;

    public function getX() {
        if(!is_null(self::$x)) {
            return self::$x;
        }

        //todo 耗时处理 START
        $x = 'xxxx';
        //todo 耗时处理 END

        return self::$x = $x;
    }

    public function getXx($key) {
        if(isset(self::$xx[$key])) {
            return self::$xx[$key];
        }

        self::$xx = array();

        //todo 耗时处理 START
        $x = 'xxxx';
        //todo 耗时处理 END

        return self::$xx[$key] = $x;
    }
}

这里可以思考下 geX 和 getXx 的应用场景

Redis 缓存

  • 优点:高并发集群读写
  • 常用数据结构: key/string/hash/set/list/sortedset

功能及应用场景

  • key 键
    • keys 线上服务杜绝使用
  • string 字符串
    • incr 原子性的计数器。对于并发的计数如PV/点击等可以使用 incr 存储,然后再入库
    • setex set 某个值同时设置过期时间
  • hash 哈希表
    • 存储结构化的数据
    • 避免将数据 json_encode 之后按照 key - value 方式存储,更加节省空间
  • set 集合
    • set元素最大可以包含(2的32次方-1)个元素,可以做 并集、交集、差集 的处理,比如 共同好友
  • list 列表
    • 一般作为队列使用。注意队列消费者使用阻塞模式的blpop
  • sortedset 有序集合
    • 一般存储有权重的数据,比如 排行榜

缓存常见问题

穿透

  • 现象

    其实就是查询一个不存在的数据,缓存中没有,然后又去数据库读,数据库中获取不到则不会更新缓存。这就导致每次的请求都会去存储层查询,失去了缓存的意义。

  • 如何避免

    如果有大量请求都穿透的话,数据库压力会非常大。所以在特定情境中需要对穿透的数据设置缓存,比如缓存一个标识,告诉请求没有数据,避免穿透。

缓存并发

  • 现象

    如果服务并发很高,当一个缓存失效时,可能出现多个请求同时查询存储,然后同时设置缓存的情况。如果并发很大,这可能造成存储服务压力过大。

  • 如何避免

    当请求发现没有缓存时,记录一个锁(需要原子性并且集群操作),然后再去数据库读取数据,最后更新缓存,删除锁。其他的请求当发现有这个锁则等待,直到锁解除,然后从缓存中读取数据。

一个容易复现的业务: 获取微信的 accessToken

背景:这个接口当时是每次刷新结果都会不一样

逻辑:微信接口请求需要带着 accessToken 参数,服务端有一个缓存保存 accessToken,如果调用的微信接口返回 accessToken 过期或者失效,就会再次获取 accessToken,更新缓存,然后重新调用微信接口。

现象:如果并发请求很多,而 accessToken 缓存恰巧失效。就会造成 accessToken 被并发请求互相抢着更新,一直无法获取到有效的 accessToken

雪崩

  • 现象

    缓存服务器重启或缓存集中在同一段短时间内失效,导致大量的强求都打到数据库或后端服务,造成服务压力过大。

  • 如何解决

    • 缓存服务器 高可用
    • key的过期时间尽量分散

Redis 缓存过期

如何删除过期建?

  • 当一个键被访问时,程序会对这个键进行检查,如果键已经过期,那么该键将被删除
  • 底层系统会在后台渐进地查找并删除那些过期的键,从而处理那些已经过期、但是不会被访问到的键

Redis 内存回收机制

  • volatile-lru -> remove the key with an expire set using an LRU algorithm
  • allkeys-lru -> remove any key accordingly to the LRU algorithm
  • volatile-random -> remove a random key with an expire set
  • allkeys-random -> remove a random key, any key
  • volatile-ttl -> remove the key with the nearest expire time (minor TTL)
  • noeviction -> don’t expire at all, just return an error on write operations

什么时候设置过期时间

  • 正常 key-value 存储,使用 expire expireat 等,在更新缓存之后更新
  • 对于hash/set/list/sortedset 数据结构,要先更新过期时间,再添加/修改数据 为什么?

以hash结构举例,如果 key_test 中现在已经有

a b c
1 2 3

那么此时如果要新增一个域为 d 值为 4 的数据


$key = 'key_test';
$redis = new Redis();
$redis->hset($key, 'd', 4);
$redis->expire($key, 60);

一般情况下都会这么写,但是如果在执行 hset 之前,key_test 恰好到期了,那么缓存中就只剩下

d
4

这个时候缓存就成脏数据了,所以代码需要这么写


$key = 'key_test';
$redis = new Redis();
$rs = $redis->expire($key, 60);
if(!$rs) {
    //当TTL更新失败时表明缓存已经丢失了,需要做回源更新
    $redis->hmset('key_test', array('a' => 1, 'b'=> 2, 'c' => 3));
}
$redis->hset('key_test', 'd', 4);

TTL

/*
 * 限制用户第一次访问站点之后,60s内不能访问
 */

$is_lock = isLock($uid);
lock($uid, $expire);

if($is_lock) {
    return false;
}

function isLock($uid) {
    $key = 'pre-' . $uid;
    $redis = new Redis();
    return $redis->exists($key);
}

function lock($uid, $expire) {
    $key = 'pre-' . $uid;
    $redis = new Redis();
    $redis->setex($key, $expire, 1);
}

这个逻辑会造成用户第一次可以访问,如果再 60 s 内一直在刷页面的话,会导致 key 的 TTL 一直在更新,将永远不能再次访问成功

[server]$ redis-cli 
127.0.0.1:6379> setex test_key 60 1
OK
127.0.0.1:6379> ttl test_key
(integer) 52
127.0.0.1:6379> setex test_key 60 1
OK
127.0.0.1:6379> ttl test_key
(integer) 59
127.0.0.1:6379> 

注意,每次设置 setex 和 expire ,key 的TTL也会重新开始计算

更新缓存的策略

先看几种不同的更新策略(假设数据库和缓存更新必成功)

  • 先更新缓存再更新数据库
  • 先淘汰缓存再更新数据库
  • 先更新数据库再更新缓存
  • 先更新数据库再淘汰缓存

分析

  • 先更新缓存再更新数据库

    会有脏数据。

    比如并发两个请求,A 请求要将数据修改为 aa , B 请求要将数据修改成 bb 。

    当 A 请求先修改了 cache 为 aa ,等待数据库连接并且发送改库命令。此时 B 请求也来了将 cache 修改为 bb ,然后也执行了数据库操作,由于抢先发送了改库命令,此时数据库数据为 bb ,然后 A 请求修改数据库数据成功为 aa 。那么此时,cache 内数据为 bb,但是数据库内数据为aa

  • 先淘汰缓存再更新数据库

    会有脏数据。

    比如此时数据库内数据为 cc ,并发来了两个请求,A 请求读取数据 , B 请求要将数据修改成 bb 。

    请求 B 需要修改数据,先将缓存删除了,此时请求 A 读取缓存,穿透缓存后读取到数据数据为 cc ,然后将缓存设置为 cc 。然而 B 请求却将数据库内数据修改为了 bb 。以后的数据读取到的都是老数据。

  • 先更新数据库再淘汰缓存

    同样是查询和修改的请求并发。没有先删除 cache ,而是先更新数据库中的数据。在数据库的更新过程中,cache 依然有效,所以并发的查询操作都可以获取到 cache 的数据。但是当数据库更新操作成功后,cache马上失效了,后续的查询操作会穿透缓存把数据从数据库中拉出来,再更新入缓存中。

    但是这个策略理论上也可能会存在有脏数据的可能。

    比如还是数据库内数据为 cc ,并发来了两个请求,A 请求读取数据 , B 请求要将数据修改成 bb 。

    请求 A 查询时缓存恰好过期了,然后去数据库读取到数据 cc ,在设置 cc 入缓存之前,请求 B 要完成数据库的更新和缓存的删除两步操作,等待 B 请求操作完毕之后,A 将 cc 写入缓存。 这种情况需要 1. 查询时没有缓存。2. 在改库操作之前查询到数据库。3. 在改库操作和删除缓存之后更新缓存。 但是改库操作执行时间一般要比查询时间长,同时改库还会表锁或行锁,查询请求必须在改库前进入并且改库后修改缓存,才能造成脏数据,概率很小。

  • 先更新数据库再更新缓存

    基本策略同先更新数据库再淘汰缓存,但是避免了删除缓存后的穿透现象,适用于超高并发的情景

Search

    Post Directory