为什么需要缓存
数据库查询是Web应用中最常见的性能瓶颈。即使经过充分的索引优化,数据库在高并发场景下仍然面临连接数限制和磁盘I/O瓶颈。缓存通过将热点数据存储在内存中,将响应时间从毫秒级降低到微秒级,同时大幅减轻数据库的查询压力。
在引入缓存之前,需要明确几个问题:哪些数据是热点数据?这些数据的读写比例如何?多大程度的数据不一致是可以接受的?
Cache-Aside模式(旁路缓存)
Cache-Aside是最常用的缓存模式,应用程序同时管理缓存和数据库:
-- 数据库端:为缓存设计高效查询
-- 缓存通常以单个对象为粒度存储,需要高效的单条查询
CREATE INDEX idx_products_id ON products (id);
CREATE INDEX idx_users_id ON users (id);
-- 缓存键设计:{实体类型}:{ID}:{版本}
-- 如:product:uuid-xxx:v1, user:uuid-yyy:v1
-- 缓存失效时的回填查询(需要高效)
SELECT
p.id, p.name, p.price, p.description,
p.stock_quantity, p.category_id,
c.name AS category_name
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.id = $1;
伪代码流程:
function getProduct(id):
// 1. 先查缓存
cached = redis.get("product:" + id)
if cached != null:
return JSON.parse(cached)
// 2. 缓存未命中,查数据库
product = db.query("SELECT ... FROM products WHERE id = $1", id)
if product == null:
return null
// 3. 写入缓存(设置过期时间防止永久占用内存)
redis.setex("product:" + id, 3600, JSON.stringify(product))
return product
function updateProduct(id, data):
// 先更新数据库
db.execute("UPDATE products SET ... WHERE id = $1", id)
// 再删除缓存(不是更新!避免并发写入导致缓存脏数据)
redis.del("product:" + id)
为什么是删除而非更新缓存? 在高并发下,两个写操作几乎同时发生时,更新缓存可能导致较晚的写操作结果被较早的缓存值覆盖。删除缓存后下次读取时重新加载,确保缓存始终与数据库一致。
Write-Through与Write-Behind模式
Write-Through(同步写穿):写操作同时更新缓存和数据库,保证强一致性,但写入延迟较高:
-- 适合读多写少、对一致性要求高的数据
-- 如:用户配置、系统设置
CREATE TABLE system_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 写穿时,应用层同时执行:
-- 1. UPDATE system_settings SET value = $2 WHERE key = $1
-- 2. redis.set("setting:" + key, value) -- 同步写入缓存
Write-Behind(异步写回):先写缓存,异步批量写入数据库,提高写入吞吐量,但存在数据丢失风险:
-- 适合写入频繁、允许短暂数据丢失的场景
-- 如:用户行为日志、页面访问计数
CREATE TABLE page_views (
page_id UUID NOT NULL,
view_date DATE NOT NULL,
view_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (page_id, view_date)
);
-- Redis中维护计数器,定期批量写入数据库
-- INCR pageview:page-id-xxx (Redis原子操作)
-- 每分钟将Redis计数批量同步到数据库:
-- INSERT INTO page_views VALUES ($1, CURRENT_DATE, $2)
-- ON CONFLICT (page_id, view_date) DO UPDATE
-- SET view_count = page_views.view_count + EXCLUDED.view_count;
三大缓存问题的防护策略
缓存穿透:查询不存在的数据,每次都落到数据库。
-- 防护:缓存空值 + 布隆过滤器
-- 对不存在的ID,缓存一个短暂的空值标记
-- redis.setex("product:not-found-id", 60, "NULL")
-- 下次同一ID查询时直接返回"不存在",不查数据库
-- 数据库端:为空查询建立高效索引也有帮助
CREATE INDEX idx_products_id_partial
ON products (id) WHERE deleted_at IS NULL;