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

缓存设计在电商系统中的实践与优化

2024-03-297.1k 阅读

电商系统中缓存的重要性

在电商系统中,高并发和快速响应是关键需求。缓存作为一种高性能的数据存储和读取机制,能显著提升系统性能。以商品详情页为例,大量用户可能同时请求查看某个热门商品的信息,如果每次请求都从数据库读取,数据库的负载会急剧增加,响应时间也会变长。而使用缓存,商品详情数据可以先从缓存中获取,若缓存中有数据,能瞬间返回给用户,大大减轻数据库压力,提高用户体验。

缓存类型及在电商中的应用场景

  1. 内存缓存:如 Redis,以其高速读写性能广泛应用于电商系统。可以缓存商品基本信息、用户购物车数据等。例如,在用户频繁操作购物车时,购物车数据可实时保存在 Redis 缓存中,减少对数据库的频繁读写。
  2. 分布式缓存:当电商系统规模扩大,单台缓存服务器无法满足需求时,分布式缓存如 Memcached 集群就发挥作用。它可以将缓存数据分布在多台服务器上,提高缓存的容量和并发处理能力,适用于缓存大量的商品图片、静态页面片段等。
  3. 本地缓存:应用服务器本地的缓存,像 Guava Cache。它适用于一些不常变化且对实时性要求不高的数据,如商品分类信息。由于数据在本地内存,读取速度极快,能有效减少网络开销。

电商系统缓存设计原则

  1. 数据一致性:缓存中的数据要与数据库中的数据保持一致。当数据库数据发生变化时,缓存中的相应数据也要及时更新。例如,商品价格调整后,缓存中的商品价格信息必须同步更新,否则用户可能看到错误的价格。
  2. 缓存命中率:尽量提高缓存命中率,使更多的请求能从缓存中获取数据。这需要合理设置缓存的过期时间、缓存数据的粒度等。比如,对于热门商品,可适当延长缓存过期时间,以提高命中率。
  3. 缓存穿透、雪崩和击穿的防范
    • 缓存穿透:指查询一个不存在的数据,每次都绕过缓存直接查询数据库。可以采用布隆过滤器来解决,先判断数据是否可能存在,若不存在则直接返回,避免查询数据库。
    • 缓存雪崩:大量缓存数据在同一时间过期,导致大量请求直接访问数据库。可以通过设置不同的过期时间,让缓存过期时间分散,避免集中过期。
    • 缓存击穿:一个热点数据过期瞬间,大量请求同时访问,击穿缓存直接访问数据库。可以使用互斥锁,保证只有一个请求去查询数据库并更新缓存,其他请求等待。

缓存设计在电商系统各模块中的实践

商品模块

  1. 商品详情缓存
    • 缓存数据结构:在 Redis 中可以使用 Hash 结构来存储商品详情。例如,一个商品的详情包括商品 ID、名称、描述、价格、库存等信息。以商品 ID 作为 Hash 的 key,其他信息作为 field - value 对。
    • 代码示例(使用 Java 和 Jedis 操作 Redis)
import redis.clients.jedis.Jedis;
import java.util.HashMap;
import java.util.Map;

public class ProductCache {
    private Jedis jedis;

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

    public void setProductDetails(long productId, String name, String description, double price, int stock) {
        Map<String, String> productMap = new HashMap<>();
        productMap.put("name", name);
        productMap.put("description", description);
        productMap.put("price", String.valueOf(price));
        productMap.put("stock", String.valueOf(stock));
        jedis.hmset("product:" + productId, productMap);
    }

    public Map<String, String> getProductDetails(long productId) {
        return jedis.hgetAll("product:" + productId);
    }
}
  1. 商品列表缓存
    • 缓存策略:对于商品列表,由于数据量较大且可能会经常分页展示,可以采用分页缓存。例如,将每页的商品列表数据缓存起来,以“商品分类 ID + 页码”作为缓存 key。
    • 代码示例(Python 和 Redis - Py 操作 Redis)
import redis

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

def set_product_list_page(category_id, page_num, product_list):
    key = f'product_list:{category_id}:{page_num}'
    r.set(key, str(product_list))

def get_product_list_page(category_id, page_num):
    key = f'product_list:{category_id}:{page_num}'
    data = r.get(key)
    if data:
        return eval(data)
    return None

用户模块

  1. 用户信息缓存
    • 缓存设计:在 Redis 中使用 String 类型缓存用户基本信息,以用户 ID 作为 key。例如,用户的昵称、头像 URL 等不经常变化的信息可以缓存起来。
    • 代码示例(Node.js 和 ioredis 操作 Redis)
const Redis = require('ioredis');
const redis = new Redis(6379, 'localhost');

async function setUserInfo(userId, nickname, avatarUrl) {
    const userInfo = JSON.stringify({nickname, avatarUrl});
    await redis.set(`user:${userId}`, userInfo);
}

async function getUserInfo(userId) {
    const data = await redis.get(`user:${userId}`);
    if (data) {
        return JSON.parse(data);
    }
    return null;
}
  1. 用户登录状态缓存
    • 实现方式:使用 Redis 的 Set 数据结构来缓存登录用户的 ID。当用户登录时,将用户 ID 添加到 Set 中;当用户登出时,从 Set 中移除用户 ID。这样可以快速判断用户是否处于登录状态。
    • 代码示例(Go 和 Redigo 操作 Redis)
package main

import (
    "github.com/gomodule/redigo/redis"
    "fmt"
)

func addLoggedInUser(conn redis.Conn, userId int) error {
    _, err := conn.Do("SADD", "logged_in_users", userId)
    return err
}

func isUserLoggedIn(conn redis.Conn, userId int) (bool, error) {
    exists, err := redis.Bool(conn.Do("SISMEMBER", "logged_in_users", userId))
    if err!= nil {
        return false, err
    }
    return exists, nil
}

func removeLoggedInUser(conn redis.Conn, userId int) error {
    _, err := conn.Do("SREM", "logged_in_users", userId)
    return err
}

订单模块

  1. 订单详情缓存
    • 缓存思路:订单详情在支付成功后,短期内可能会被用户频繁查看。可以在 Redis 中使用 Hash 结构缓存订单详情,以订单 ID 作为 key,订单的各个字段如订单金额、商品列表、收货地址等作为 field - value 对。
    • 代码示例(C# 和 StackExchange.Redis 操作 Redis)
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

class OrderCache {
    private ConnectionMultiplexer redis;
    private IDatabase db;

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

    public void SetOrderDetails(string orderId, decimal amount, string productList, string shippingAddress) {
        var hash = new HashEntry[] {
            new HashEntry("amount", amount.ToString()),
            new HashEntry("productList", productList),
            new HashEntry("shippingAddress", shippingAddress)
        };
        db.HashSet($"order:{orderId}", hash);
    }

    public Dictionary<string, string> GetOrderDetails(string orderId) {
        var hashEntries = db.HashGetAll($"order:{orderId}");
        return hashEntries.ToDictionary(entry => entry.Name.ToString(), entry => entry.Value.ToString());
    }
}
  1. 订单列表缓存
    • 缓存策略:对于用户的订单列表,可以采用分页缓存。以“用户 ID + 页码”作为缓存 key,缓存每页的订单列表数据。这样可以快速返回用户的订单列表信息,减少数据库查询压力。
    • 代码示例(Ruby 和 Redis - Ruby 操作 Redis)
require'redis'

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

def set_user_order_list_page(user_id, page_num, order_list)
    key = "user:#{user_id}:orders:#{page_num}"
    redis.set(key, order_list.to_json)
end

def get_user_order_list_page(user_id, page_num)
    key = "user:#{user_id}:orders:#{page_num}"
    data = redis.get(key)
    data? JSON.parse(data) : nil
end

电商系统缓存优化策略

  1. 缓存预热:在系统启动时,提前将一些热点数据加载到缓存中,避免在系统运行初期由于缓存未命中导致大量请求直接访问数据库。例如,在电商系统启动时,将热门商品的详情、首页轮播图商品等数据加载到缓存中。可以通过编写启动脚本,在系统启动时调用缓存加载接口来实现。
  2. 动态调整缓存过期时间:根据数据的访问频率和变化频率动态调整缓存过期时间。对于访问频率高且变化频率低的数据,适当延长过期时间;对于访问频率低且变化频率高的数据,缩短过期时间。例如,可以通过定时任务监控商品的访问次数和数据库中商品数据的变更情况,根据设定的规则动态调整商品缓存的过期时间。
  3. 缓存数据压缩:当缓存数据量较大时,对缓存数据进行压缩可以减少内存占用和网络传输开销。例如,对于商品描述等较长的文本数据,可以在存入缓存前进行压缩(如使用 Gzip 压缩算法),在读取缓存数据时再进行解压缩。
  4. 缓存分层:采用多级缓存架构,如本地缓存 + 分布式缓存。先从本地缓存读取数据,若未命中再从分布式缓存读取。这样可以减少网络开销,提高缓存读取速度。例如,在应用服务器上使用 Guava Cache 作为本地缓存,同时使用 Redis 作为分布式缓存。

缓存与数据库的一致性维护

  1. 先更新数据库,再更新缓存:这种方式简单直观,但在高并发情况下可能会出现数据不一致问题。例如,两个请求同时更新数据库和缓存,第一个请求更新数据库后还未更新缓存,第二个请求更新数据库并更新了缓存,此时第一个请求再更新缓存,就会导致缓存数据是旧数据。
  2. 先更新数据库,再删除缓存:这是比较常用的方式。当数据发生变化时,先更新数据库,然后删除对应的缓存数据。下次请求时,缓存未命中,会从数据库读取最新数据并重新写入缓存。但是,在高并发场景下,如果一个请求删除缓存后,另一个请求在读取数据库并更新缓存之前,又有请求更新了数据库,就会导致缓存中的数据与数据库不一致。为了解决这个问题,可以采用延时双删策略,即删除缓存后,等待一段时间(如 500 毫秒)再删除一次缓存,确保在这段时间内数据库更新操作已经完成,新的数据能够正确写入缓存。
  3. 使用消息队列:将数据库更新操作和缓存更新操作放入消息队列中。数据库更新成功后,发送一条消息到消息队列,由消息队列的消费者负责更新缓存。这样可以保证数据库和缓存的更新顺序,避免高并发情况下的数据不一致问题。例如,可以使用 Kafka 等消息队列来实现。

缓存监控与性能调优

  1. 缓存监控指标
    • 缓存命中率:通过监控缓存命中率,可以了解缓存的使用效率。计算公式为:缓存命中次数 / (缓存命中次数 + 缓存未命中次数)。如果命中率过低,可能需要调整缓存策略,如扩大缓存容量、优化缓存过期时间等。
    • 缓存内存使用情况:监控缓存占用的内存大小,避免内存溢出。可以通过 Redis 的 INFO 命令获取内存使用相关信息,如 used_memory 表示已使用的内存量。
    • 缓存读写性能:监控缓存的读写速度,查看是否存在性能瓶颈。可以使用工具如 Redis - Benchmark 来测试 Redis 的读写性能。
  2. 性能调优措施
    • 优化缓存数据结构:根据数据的特点选择合适的缓存数据结构。例如,对于需要频繁查询某个字段的场景,使用 Hash 结构可能比 String 结构更合适,因为 Hash 结构可以直接通过 field 快速获取对应的值。
    • 调整缓存配置参数:如 Redis 的 maxmemory 参数,合理设置缓存的最大内存限制。同时,根据业务需求选择合适的内存淘汰策略,如 allkeys - lru(删除最近最少使用的键)、volatile - lru(从设置了过期时间的键中删除最近最少使用的键)等。
    • 增加缓存服务器:当单台缓存服务器无法满足性能需求时,可以增加缓存服务器,构建分布式缓存集群,提高缓存的并发处理能力和存储容量。

在电商系统的后端开发中,合理设计和优化缓存是提升系统性能、降低数据库压力的关键。通过遵循缓存设计原则,在各模块中合理应用缓存,并采取有效的优化和一致性维护策略,同时做好缓存监控与性能调优,可以打造出高性能、高可用的电商系统。