MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

缓存设计中的TTL优化技巧

2021-12-042.9k 阅读

缓存基础知识回顾

在深入探讨 TTL(Time - To - Live,生存时间)优化技巧之前,让我们先简要回顾一下缓存的基础知识。缓存是一种用于存储数据副本的临时存储机制,目的是为了提高数据的访问速度。在后端开发中,缓存通常被用于存储频繁访问但不经常变化的数据,例如数据库查询结果、渲染后的页面片段等。

缓存的工作原理基于一个简单的原则:当应用程序请求数据时,首先检查缓存中是否存在该数据。如果存在,则直接从缓存中获取数据并返回给应用程序,从而避免了从较慢的数据源(如数据库)获取数据的开销。如果缓存中不存在所需数据,则从数据源获取数据,将其存储到缓存中,然后返回给应用程序。

缓存的常见类型

  1. 内存缓存:例如 Redis 和 Memcached,它们将数据存储在服务器的内存中,因此具有非常高的读写速度。内存缓存通常用于存储热点数据,如用户会话信息、频繁查询的数据库结果等。
  2. 分布式缓存:适用于大型分布式系统,它允许在多个服务器之间共享缓存数据。这种类型的缓存可以提高系统的可扩展性和容错性,常见的分布式缓存有 Redis Cluster 等。
  3. 本地缓存:存储在应用程序进程内部的缓存,例如 Java 中的 Guava Cache。本地缓存的优点是访问速度极快,但缺点是它的作用范围仅限于单个应用程序实例,不适合在分布式环境中共享数据。

缓存的基本操作

  1. 设置(Set):将数据存储到缓存中,通常会关联一个 TTL 值。例如,在 Redis 中,可以使用 SET key value EX seconds 命令将 value 存储到 key 对应的缓存位置,并设置 TTL 为 seconds 秒。
  2. 获取(Get):从缓存中检索数据。在 Redis 中,使用 GET key 命令获取 key 对应的缓存值。
  3. 删除(Delete):从缓存中移除数据。在 Redis 中,通过 DEL key 命令删除指定 key 的缓存数据。

TTL 在缓存设计中的作用

TTL 是缓存设计中的一个关键参数,它定义了缓存数据的有效时间。一旦 TTL 到期,缓存数据将被视为过期,下次访问该数据时,缓存将不会返回过期数据,而是触发从数据源重新获取数据的操作。

控制数据一致性

TTL 的主要作用之一是控制缓存数据与数据源数据的一致性。通过设置适当的 TTL,可以确保缓存中的数据在一定时间内与数据源保持同步。例如,对于经常变化的数据,如股票价格,应设置较短的 TTL,以保证用户获取到的是相对最新的数据。而对于不经常变化的数据,如网站的静态配置信息,可以设置较长的 TTL,以减少从数据源获取数据的频率,提高系统性能。

优化缓存空间使用

合理设置 TTL 还有助于优化缓存空间的使用。缓存的内存空间通常是有限的,通过设置 TTL,过期的数据会自动从缓存中移除,为新的数据腾出空间。这可以防止缓存被大量无用的旧数据填满,从而确保缓存始终能够存储最新和最有用的数据。

减轻后端负载

适当的 TTL 设置可以有效地减轻后端数据源(如数据库)的负载。如果 TTL 设置得过长,可能会导致缓存中的数据与数据源的数据长时间不一致,从而影响应用程序的正确性。但如果 TTL 设置得过短,会频繁地从数据源获取数据,增加后端负载。因此,找到一个合适的 TTL 平衡点对于系统性能的优化至关重要。

TTL 优化技巧

动态 TTL 设置

  1. 基于数据变化频率设置 TTL
    • 对于不同类型的数据,其变化频率差异很大。例如,电商网站上的商品库存信息可能每分钟甚至每秒都在变化,而商品的基本描述信息(如名称、品牌等)可能几天甚至几周才会变化一次。
    • 在代码实现上,以 Python 和 Redis 为例:
import redis

r = redis.Redis(host='localhost', port=6379, db = 0)

# 假设商品库存信息的变化频率高
def get_product_stock(product_id):
    key = f'product_stock:{product_id}'
    stock = r.get(key)
    if stock is None:
        # 从数据库获取库存
        stock_from_db = get_stock_from_db(product_id)
        r.setex(key, 60, stock_from_db) # 设置 TTL 为 60 秒
        return stock_from_db
    return stock.decode('utf - 8')


# 假设商品基本描述信息变化频率低
def get_product_description(product_id):
    key = f'product_description:{product_id}'
    description = r.get(key)
    if description is None:
        # 从数据库获取描述
        description_from_db = get_description_from_db(product_id)
        r.setex(key, 86400, description_from_db) # 设置 TTL 为一天(86400 秒)
        return description_from_db
    return description.decode('utf - 8')
  1. 根据业务时间设置 TTL
    • 在某些业务场景中,数据的有效性与业务时间相关。例如,电商平台的促销活动,活动期间某些商品的价格缓存 TTL 可以设置为活动持续时间。
    • 以下是一个简单的示例,使用 Java 和 Redis 实现:
import redis.clients.jedis.Jedis;

public class PromotionCache {
    private Jedis jedis;

    public PromotionCache() {
        jedis = new Jedis("localhost", 6379);
    }

    public String getPromotionPrice(String productId, long promotionStartTime, long promotionEndTime) {
        String key = "promotion_price:" + productId;
        String price = jedis.get(key);
        if (price == null) {
            long currentTime = System.currentTimeMillis();
            if (currentTime >= promotionStartTime && currentTime <= promotionEndTime) {
                // 从数据库获取促销价格
                String priceFromDb = getPromotionPriceFromDb(productId);
                long ttl = promotionEndTime - currentTime;
                jedis.setex(key, (int) (ttl / 1000), priceFromDb);
                return priceFromDb;
            } else {
                // 非促销时间,获取正常价格
                return getNormalPrice(productId);
            }
        }
        return price;
    }

    private String getPromotionPriceFromDb(String productId) {
        // 模拟从数据库获取促销价格
        return "19.99";
    }

    private String getNormalPrice(String productId) {
        // 模拟从数据库获取正常价格
        return "29.99";
    }
}

缓存预热与 TTL 配合

  1. 缓存预热的概念
    • 缓存预热是指在系统启动或特定场景下,提前将数据加载到缓存中,以避免在系统运行初期由于缓存未命中导致的性能问题。例如,电商网站在大促活动前,可以提前将热门商品的信息、价格等数据加载到缓存中。
  2. 结合 TTL 进行缓存预热
    • 在缓存预热时,需要根据数据的特性合理设置 TTL。对于预热的数据,如果预计在一段时间内不会变化,可以设置较长的 TTL。例如,对于一些活动规则说明等相对固定的数据,在预热时可以设置 TTL 为活动持续时间加上一定的缓冲时间。
    • 以 Node.js 和 Redis 为例:
const redis = require('redis');
const client = redis.createClient(6379, 'localhost');

// 缓存预热函数
function warmUpCache() {
    const productIds = [1, 2, 3];// 假设这是热门商品 ID
    productIds.forEach((productId) => {
        const key = `product:${productId}`;
        // 假设从数据库获取数据的函数
        const dataFromDb = getProductDataFromDb(productId);
        client.setex(key, 3600 * 24 * 3, dataFromDb); // 设置 TTL 为 3 天
    });
}

function getProductDataFromDb(productId) {
    // 模拟从数据库获取产品数据
    return `Product ${productId} data`;
}

warmUpCache();

多层缓存与 TTL 策略

  1. 多层缓存架构
    • 多层缓存架构通常包括本地缓存和分布式缓存。本地缓存(如 Guava Cache)可以提供极快的访问速度,用于存储应用程序本地频繁访问的数据。分布式缓存(如 Redis)则用于在多个应用程序实例之间共享数据,并提供更大的缓存容量。
  2. TTL 策略在多层缓存中的应用
    • 在多层缓存中,不同层的缓存可以设置不同的 TTL。一般来说,本地缓存的 TTL 可以设置得较短,因为它的作用范围有限,过期后重新从分布式缓存或数据源获取数据的开销相对较小。而分布式缓存的 TTL 可以设置得相对较长,以减少对数据源的访问频率。
    • 以下是一个简单的 Java 多层缓存示例,使用 Guava Cache 作为本地缓存,Redis 作为分布式缓存:
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import redis.clients.jedis.Jedis;

import java.util.concurrent.TimeUnit;

public class MultiLevelCache {
    private Cache<String, String> localCache;
    private Jedis jedis;

    public MultiLevelCache() {
        localCache = CacheBuilder.newBuilder()
               .expireAfterWrite(60, TimeUnit.SECONDS)
               .build();
        jedis = new Jedis("localhost", 6379);
    }

    public String getValue(String key) {
        String value = localCache.getIfPresent(key);
        if (value == null) {
            value = jedis.get(key);
            if (value != null) {
                localCache.put(key, value);
            } else {
                // 从数据源获取数据
                value = getValueFromDb(key);
                if (value != null) {
                    jedis.setex(key, 3600, value); // Redis 中设置 TTL 为 1 小时
                    localCache.put(key, value);
                }
            }
        }
        return value;
    }

    private String getValueFromDb(String key) {
        // 模拟从数据库获取数据
        return "Value for " + key;
    }
}

缓存更新与 TTL 调整

  1. 缓存更新策略
    • 当数据源中的数据发生变化时,需要相应地更新缓存中的数据。常见的缓存更新策略有写后失效(Write - Through)、写前失效(Write - Behind)和读写失效(Read - Through)。
    • 写后失效:在更新数据源后,立即使缓存中的数据失效,下次访问时重新从数据源加载数据到缓存。例如,在更新数据库中的用户信息后,使用 DEL key 命令删除 Redis 中对应的用户缓存数据。
    • 写前失效:在更新数据源之前,先使缓存中的数据失效。这种策略可以确保在数据更新期间,其他请求不会获取到旧的缓存数据。
    • 读写失效:当读取到过期的缓存数据时,先从数据源获取最新数据,更新缓存,然后返回给应用程序。
  2. 结合 TTL 调整
    • 在缓存更新时,根据数据的变化情况调整 TTL。例如,如果数据更新后变得更加稳定,可以适当延长 TTL;如果数据更新后可能会更频繁地变化,则缩短 TTL。
    • 以 Python 和 Redis 为例,假设使用写后失效策略,并根据数据更新情况调整 TTL:
import redis

r = redis.Redis(host='localhost', port=6379, db = 0)

def update_user_info(user_id, new_info):
    # 更新数据库
    update_user_info_in_db(user_id, new_info)
    key = f'user:{user_id}'
    r.delete(key)
    # 根据新信息的稳定性调整 TTL
    if is_more_stable(new_info):
        r.setex(key, 3600 * 24 * 7, new_info) # 设置 TTL 为一周
    else:
        r.setex(key, 3600, new_info) # 设置 TTL 为 1 小时


def is_more_stable(info):
    # 这里简单模拟判断信息稳定性的逻辑
    return len(info) > 10

避免缓存雪崩与 TTL 分散

  1. 缓存雪崩的概念
    • 缓存雪崩是指在短时间内,大量的缓存数据同时过期,导致大量请求直接访问后端数据源,从而使后端数据源压力骤增,甚至可能导致系统崩溃。例如,在电商大促活动结束后,如果所有与活动相关的缓存数据的 TTL 都设置为活动结束的同一时间,那么活动结束后瞬间,所有这些缓存数据都会过期,大量请求会涌向数据库。
  2. 通过 TTL 分散避免缓存雪崩
    • 为了避免缓存雪崩,可以采用 TTL 分散的方法。即在设置缓存 TTL 时,不要将所有数据的 TTL 设置为相同的值,而是在一个合理的范围内随机或按照一定规律分散设置 TTL。
    • 以 Go 和 Redis 为例:
package main

import (
    "fmt"
    "math/rand"
    "time"

    "github.com/go - redis/redis/v8"
)

var rdb *redis.Client
var ctx = context.Background()

func init() {
    rdb = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })
    rand.Seed(time.Now().UnixNano())
}

func setWithRandomTTL(key string, value string) {
    minTTL := 3600 // 1 小时
    maxTTL := 7200 // 2 小时
    ttl := minTTL + rand.Intn(maxTTL - minTTL)
    err := rdb.Set(ctx, key, value, time.Duration(ttl)*time.Second).Err()
    if err != nil {
        fmt.Println("Error setting key:", err)
    }
}

监控与调整 TTL

  1. 监控缓存指标
    • 为了有效地优化 TTL,需要监控一些关键的缓存指标。例如,缓存命中率(Cache Hit Ratio),它表示缓存命中次数与总请求次数的比例。高缓存命中率意味着缓存有效地减少了对后端数据源的访问。可以通过在应用程序中添加计数器来统计缓存命中和未命中的次数,然后计算缓存命中率。
    • 以下是一个简单的 Python 示例,用于统计缓存命中率:
cache_hit_count = 0
cache_miss_count = 0

def get_data_from_cache(key):
    global cache_hit_count, cache_miss_count
    value = r.get(key)
    if value:
        cache_hit_count += 1
        return value.decode('utf - 8')
    cache_miss_count += 1
    # 从数据源获取数据
    value = get_data_from_db(key)
    r.setex(key, 3600, value)
    return value


def calculate_cache_hit_ratio():
    total_requests = cache_hit_count + cache_miss_count
    if total_requests == 0:
        return 0
    return cache_hit_count / total_requests
  1. 根据监控结果调整 TTL
    • 如果发现缓存命中率较低,可以考虑延长一些频繁访问但未命中的数据的 TTL,以提高缓存的利用率。相反,如果发现某些数据虽然设置了较长的 TTL,但实际上经常变化,导致应用程序获取到过期数据,可以缩短这些数据的 TTL。
    • 例如,通过定期检查缓存命中率,并根据结果调整 TTL:
import time

while True:
    hit_ratio = calculate_cache_hit_ratio()
    if hit_ratio < 0.8:
        # 遍历部分频繁访问的 key 并延长 TTL
        for key in frequently_accessed_keys:
            current_ttl = r.ttl(key)
            if current_ttl > 0:
                r.setex(key, current_ttl * 2, r.get(key))
    time.sleep(3600) # 每小时检查一次
  1. 使用缓存分析工具
    • 一些缓存系统提供了分析工具来帮助监控和优化缓存。例如,Redis 提供了 INFO 命令,可以获取 Redis 服务器的各种统计信息,包括缓存的使用情况、命中率等。此外,还有一些第三方工具,如 RedisInsight,可以直观地查看和分析 Redis 缓存的各项指标,方便开发人员进行 TTL 优化等操作。

不同业务场景下的 TTL 优化实例

新闻网站

  1. 新闻文章缓存
    • 在新闻网站中,新闻文章的内容通常在发布后不会频繁修改。对于已发布的新闻文章,可以设置较长的 TTL,例如一天或更长时间。但对于一些突发新闻,由于其时效性非常强,可能需要设置较短的 TTL,比如几分钟。
    • 以下是一个简单的 PHP 和 Redis 实现示例:
<?php
$redis = new Redis();
$redis->connect('localhost', 6379);

function getNewsArticle($articleId) {
    global $redis;
    $key = 'news_article:'. $articleId;
    $article = $redis->get($key);
    if ($article === false) {
        // 从数据库获取新闻文章
        $article = getArticleFromDb($articleId);
        if ($article) {
            if (isBreakingNews($article)) {
                $redis->setex($key, 300, $article); // 突发新闻设置 TTL 为 5 分钟
            } else {
                $redis->setex($key, 86400, $article); // 普通新闻设置 TTL 为一天
            }
        }
    }
    return $article;
}

function getArticleFromDb($articleId) {
    // 模拟从数据库获取新闻文章
    return "Article content for ID $articleId";
}

function isBreakingNews($article) {
    // 简单模拟判断是否为突发新闻
    return strpos($article, 'Breaking News')!== false;
}
?>
  1. 热门新闻排行榜缓存
    • 热门新闻排行榜可能每分钟或每几分钟更新一次,因此其缓存的 TTL 可以设置为更新间隔时间。例如,设置 TTL 为 60 秒或 300 秒,以确保用户看到的排行榜数据相对较新。
function getPopularNewsList() {
    global $redis;
    $key = 'popular_news_list';
    $newsList = $redis->get($key);
    if ($newsList === false) {
        // 从数据库获取热门新闻列表
        $newsList = getPopularNewsFromDb();
        if ($newsList) {
            $redis->setex($key, 60, $newsList); // 设置 TTL 为 60 秒
        }
    }
    return $newsList;
}

function getPopularNewsFromDb() {
    // 模拟从数据库获取热门新闻列表
    return "News ID 1, News ID 2, News ID 3";
}

在线游戏

  1. 玩家角色信息缓存
    • 在在线游戏中,玩家角色的基本信息(如等级、职业等)可能不会频繁变化,可以设置较长的 TTL,例如数小时甚至一天。但玩家的实时状态信息(如当前位置、生命值等)可能需要更短的 TTL,比如几秒钟或一分钟,以确保游戏的实时性。
    • 以下是一个 C# 和 Redis 实现示例:
using StackExchange.Redis;
using System;

class GameCache
{
    private ConnectionMultiplexer redis;
    private IDatabase db;

    public GameCache()
    {
        redis = ConnectionMultiplexer.Connect("localhost:6379");
        db = redis.GetDatabase();
    }

    public string GetPlayerBasicInfo(string playerId)
    {
        string key = $"player_basic:{playerId}";
        RedisValue value = db.StringGet(key);
        if (value.HasValue)
        {
            return value;
        }
        // 从数据库获取玩家基本信息
        string basicInfo = GetPlayerBasicInfoFromDb(playerId);
        if (!string.IsNullOrEmpty(basicInfo))
        {
            db.StringSet(key, basicInfo, TimeSpan.FromHours(1)); // 设置 TTL 为 1 小时
        }
        return basicInfo;
    }

    public string GetPlayerRealTimeStatus(string playerId)
    {
        string key = $"player_status:{playerId}";
        RedisValue value = db.StringGet(key);
        if (value.HasValue)
        {
            return value;
        }
        // 从数据库获取玩家实时状态信息
        string realTimeStatus = GetPlayerRealTimeStatusFromDb(playerId);
        if (!string.IsNullOrEmpty(realTimeStatus))
        {
            db.StringSet(key, realTimeStatus, TimeSpan.FromSeconds(30)); // 设置 TTL 为 30 秒
        }
        return realTimeStatus;
    }

    private string GetPlayerBasicInfoFromDb(string playerId)
    {
        // 模拟从数据库获取玩家基本信息
        return $"Player {playerId} basic info";
    }

    private string GetPlayerRealTimeStatusFromDb(string playerId)
    {
        // 模拟从数据库获取玩家实时状态信息
        return $"Player {playerId} real - time status";
    }
}
  1. 游戏排行榜缓存
    • 游戏排行榜的更新频率取决于游戏的类型和玩法。对于一些实时竞技类游戏,排行榜可能每秒或每几秒更新一次,缓存的 TTL 应设置得非常短,例如 1 - 5 秒。而对于一些休闲游戏,排行榜可能几分钟更新一次,TTL 可以设置为相应的更新间隔时间。
public string GetGameLeaderboard()
{
    string key = "game_leaderboard";
    RedisValue value = db.StringGet(key);
    if (value.HasValue)
    {
        return value;
    }
    // 从数据库获取游戏排行榜
    string leaderboard = GetGameLeaderboardFromDb();
    if (!string.IsNullOrEmpty(leaderboard))
    {
        if (IsRealTimeGame())
        {
            db.StringSet(key, leaderboard, TimeSpan.FromSeconds(3)); // 实时竞技游戏设置 TTL 为 3 秒
        }
        else
        {
            db.StringSet(key, leaderboard, TimeSpan.FromMinutes(5)); // 休闲游戏设置 TTL 为 5 分钟
        }
    }
    return leaderboard;
}

private string GetGameLeaderboardFromDb()
{
    // 模拟从数据库获取游戏排行榜
    return "Player 1: 100 points, Player 2: 90 points";
}

private bool IsRealTimeGame()
{
    // 简单模拟判断是否为实时竞技游戏
    return true;
}

电商平台

  1. 商品详情缓存
    • 商品的基本详情(如名称、描述、图片等)可能变化不频繁,可以设置较长的 TTL,比如数小时或一天。但商品的价格和库存信息变化相对频繁,需要设置较短的 TTL。例如,价格 TTL 可以设置为几分钟,库存 TTL 可以设置为 1 - 5 分钟,具体取决于业务需求。
    • 以下是一个 Ruby 和 Redis 实现示例:
require'redis'

redis = Redis.new(host: 'localhost', port: 6379)

def get_product_detail(product_id)
    key = "product_detail:#{product_id}"
    detail = redis.get(key)
    if detail.nil?
        # 从数据库获取商品详情
        detail = get_product_detail_from_db(product_id)
        if detail
            redis.setex(key, 3600, detail) # 设置 TTL 为 1 小时
        end
    end
    detail
end

def get_product_price(product_id)
    key = "product_price:#{product_id}"
    price = redis.get(key)
    if price.nil?
        # 从数据库获取商品价格
        price = get_product_price_from_db(product_id)
        if price
            redis.setex(key, 300, price) # 设置 TTL 为 5 分钟
        end
    end
    price
end

def get_product_stock(product_id)
    key = "product_stock:#{product_id}"
    stock = redis.get(key)
    if stock.nil?
        # 从数据库获取商品库存
        stock = get_product_stock_from_db(product_id)
        if stock
            redis.setex(key, 60, stock) # 设置 TTL 为 1 分钟
        end
    end
    stock
end

def get_product_detail_from_db(product_id)
    # 模拟从数据库获取商品详情
    "Product #{product_id} detail"
end

def get_product_price_from_db(product_id)
    # 模拟从数据库获取商品价格
    "19.99"
end

def get_product_stock_from_db(product_id)
    # 模拟从数据库获取商品库存
    "100"
end
  1. 购物车缓存
    • 用户购物车的内容在用户操作期间可能会频繁变化,因此缓存的 TTL 可以设置为用户会话的预计时长。例如,如果用户平均在电商平台上的购物会话时长为 30 分钟,可以设置购物车缓存的 TTL 为 30 - 45 分钟,以确保在用户操作过程中购物车数据始终在缓存中可用。
def get_shopping_cart(user_id)
    key = "shopping_cart:#{user_id}"
    cart = redis.get(key)
    if cart.nil?
        # 从数据库获取购物车
        cart = get_shopping_cart_from_db(user_id)
        if cart
            redis.setex(key, 2700, cart) # 设置 TTL 为 45 分钟
        end
    end
    cart
end

def get_shopping_cart_from_db(user_id)
    # 模拟从数据库获取购物车
    "Product 1, Product 2"
end

通过对不同业务场景下 TTL 优化的深入分析和示例展示,我们可以看到合理设置 TTL 对于提高系统性能、保证数据一致性以及优化资源利用具有至关重要的作用。在实际的后端开发中,需要根据具体的业务需求和数据特性,灵活运用各种 TTL 优化技巧,以构建高效、稳定的缓存系统。同时,持续监控和调整 TTL 也是确保缓存系统始终处于最佳运行状态的关键环节。