数据库优化与缓存设计:MySQL / Redis / MongoDB 实战指南
摘要:后端性能瓶颈十有八九出在数据库。本文不是教科书式的概念堆砌——而是一个电商系统从"卡到崩溃"到"丝滑流畅"的真实优化路径。MySQL 索引调优、Redis 三级缓存架构、MongoDB 文档模型设计。读完你会有一个清晰的判断:什么场景该用什么存储,以及怎么把它用好。
目录
- 一、从 3 秒到 50ms:一条请求的优化之旅
- 二、MySQL:索引不是玄学
- 三、Redis:三级缓存架构设计
- 四、MongoDB:灵活数据的正确打开方式
- 五、完整项目:三者协同落地
- 六、生产环境 Checklist
- 七、总结:一张图记住全部
一、从 3 秒到 50ms:一条请求的优化之旅
1.1 优化前的惨状
一个电商详情页需要展示:商品基本信息、实时库存、用户浏览记录、同类推荐。
新手写法的技术栈: 纯 MySQL,一张表打天下。
请求 → SELECT * FROM product WHERE id = 1
→ SELECT stock FROM inventory WHERE product_id = 1
→ SELECT * FROM browse_history WHERE user_id = 123 ORDER BY time DESC
→ SELECT * FROM product WHERE category_id = 1 ORDER BY sales DESC LIMIT 10
→ 模板渲染 → 响应
4 次 SQL,没有索引优化,没有缓存,线上 QPS 刚过 100 就开始超时,P99 延迟飙到 2.8 秒。
真实教训: 不是数据库不够快,是你没用对方式。
1.2 优化后的架构
请求 → Caffeine(L1,命中率 85%) → 直接返回(< 1ms)
→ Caffeine miss → Redis(L2,命中率 95%)
→ Redis miss → MySQL(L3,索引查询 < 5ms)
→ MongoDB(浏览记录,一次查询拿全) → 聚合渲染 → 50ms 响应
核心思路只有一句话:让数据离计算越近越好,按数据"形状"选择存储。
1.3 选型决策树:MySQL vs Redis vs MongoDB
你的数据长什么样?
├── 结构固定、需要事务、精确查询(订单/用户/账户)
│ └── MySQL + 索引优化
│
├── 需要亚毫秒响应、高并发读写(热点商品/计数器/会话)
│ └── Redis(多级缓存:Caffeine → Redis → MySQL)
│
├── Schema 多变、嵌套结构深(商品详情/日志/配置)
│ └── MongoDB(文档模型,一个查询拿全数据)
│
└── 全文搜索(用户搜商品)
└── Elasticsearch(本文不展开)
| 存储 | 读延迟 | 适合的数据 | 不适合的数据 |
|---|---|---|---|
| MySQL | 1-50ms | 强一致性、事务、JOIN | 高频读热点数据 |
| Redis | < 1ms | 计数器、缓存、会话、排行榜 | 大体积数据(>10KB/key) |
| MongoDB | 1-10ms | 多变 Schema、日志、嵌套数据 | 复杂 JOIN、强事务 |
二、MySQL:索引不是玄学
2.1 EXPLAIN 深度解读
面试官问你"怎么优化慢查询",标准答案是 EXPLAIN。但看三个字段就够了:
EXPLAIN SELECT * FROM product WHERE category_id = 3 AND price < 100 ORDER BY sales DESC;
| 字段 | 含义 | 🚩 红线 | ✅ 健康 |
|---|---|---|---|
| type | 数据访问方式 | ALL(全表扫描) |
ref / range / const |
| key | 实际使用的索引 | NULL(没走索引) |
索引名称 |
| Extra | 额外操作 | Using filesort / Using temporary |
Using index(覆盖索引) |
| rows | 预估扫描行数 | 远大于实际返回行数 | 接近实际返回行数 |
一个真实案例:
-- 表有 500 万行,这条 SQL 跑了 2.3 秒
EXPLAIN SELECT * FROM product WHERE status = 1 ORDER BY created_at DESC LIMIT 20;
-- type: ALL, rows: 5,000,000, Extra: Using where; Using filesort
-- 加索引后:
ALTER TABLE product ADD INDEX idx_status_created(status, created_at);
-- type: ref, rows: 2,500,000, Extra: Using index condition(仍然用了 filesort,因为范围查询)
-- 再优化:用覆盖索引避免 filesort
ALTER TABLE product ADD INDEX idx_status_created_cover(status, created_at, id, title, price);
-- Extra: Using index(仅扫描索引,不读数据行),耗时降到 8ms
关键认知:
Extra: Using index意味着查询完全在索引树上完成——这是 MySQL 查询优化的终极目标。代价是索引会更大,写入会稍慢,需要根据读写比做取舍。
2.2 建表与索引设计(含验证)
-- ===== 商品主表 =====
CREATE TABLE product (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
category_id INT NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
sales INT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 核心索引:覆盖"按分类+价格筛选+排序"的高频查询
INDEX idx_cat_price (category_id, price),
-- 覆盖索引:避免回表
INDEX idx_cat_sales_cover (category_id, sales, status, title, price),
-- 状态+时间(用于后台管理分页查询)
INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===== 订单表 =====
CREATE TABLE `order` (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_order_no (order_no),
INDEX idx_user_status (user_id, status), -- 用户查自己的订单
INDEX idx_created (created_at) -- 按时间区间查
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
索引设计四原则:
| 原则 | 解释 | 反例 |
|---|---|---|
| 最左前缀 | 联合索引 (A,B,C) 能覆盖 A / A+B / A+B+C,但跳列失效 |
WHERE B=? 不走 (A,B,C) |
| 高选择性优先 | 区分度高的列放前面(user_id 优于 status) |
INDEX(status, title) |
| 覆盖索引 | 查询列都在索引中,省去回表(Extra: Using index) |
SELECT * 破坏覆盖 |
| 避免函数包裹 | WHERE YEAR(col)=2024 → 索引失效 |
改为范围查询 |
验证 SQL:
-- ✓ 完美命中 idx_cat_sales_cover(覆盖索引 + 无 filesort)
EXPLAIN SELECT id, title, price, sales FROM product
WHERE category_id = 3 AND sales > 0 ORDER BY sales DESC LIMIT 10;
-- type: range, key: idx_cat_sales_cover, Extra: Using where; Using index
-- ✗ 索引部分失效(跳过了 sales)
EXPLAIN SELECT * FROM product
WHERE category_id = 3 AND status = 1;
-- key: idx_cat_price(只用了 category_id 部分,Extra 含 Using index condition)
-- ✗ 函数包裹导致完全失效
EXPLAIN SELECT * FROM product WHERE YEAR(created_at) = 2024;
-- type: ALL → 改为: WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'
2.3 连接池与慢查询配置
# application.yml — HikariCP(Spring Boot 默认连接池)
spring:
datasource:
url: jdbc:mysql://localhost:3306/shop?useSSL=false&characterEncoding=utf8mb4
hikari:
maximum-pool-size: 20 # CPU 核数 * 2 + 硬盘数(通用公式)
minimum-idle: 5 # 保持热连接,避免冷启动
connection-timeout: 3000 # 等连接超时:3s,过短会导致误报
idle-timeout: 600000 # 空闲超时:10min
max-lifetime: 1800000 # 连接最大存活:30min(必须 < MySQL wait_timeout)
leak-detection-threshold: 10000 # 连接泄漏告警:10s
配置要点:
max-lifetime必须小于 MySQL 的wait_timeout(默认 8 小时),否则应用持有一个已被 MySQL 关闭的连接,请求直接报CommunicationsException。
-- 生产环境必开慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.1; -- 100ms 以上记录
SET GLOBAL log_queries_not_using_indexes = ON;
-- 配合 pt-query-digest 工具做定期分析
三、Redis:三级缓存架构设计
3.1 缓存策略选型对比
| 策略 | 读流程 | 写流程 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 缓存命中返回 / miss 查 DB 回填 | 先写 DB,再删缓存 | 最终一致 | 读多写少(本文采用) |
| Read/Write Through | 缓存层代理读 | 缓存层代理写 DB | 强一致 | 数据一致性要求高 |
| Write Behind | 同上 | 只写缓存,异步批量刷 DB | 弱一致 | 高写入吞吐,可容丢失 |
本文采用 Cache Aside + 多级 TTL:
读链路: Caffeine(L1, 1min, 命中率 ~85%)
↓ miss
Redis(L2, 10min±随机抖动, 命中率 ~95%)
↓ miss
MySQL(L3, 索引查询 < 5ms)
↓ 回填
Redis(L2, TTL=600s) → Caffeine(L1, TTL=60s)
写链路: MySQL(UPDATE) → Redis(DELETE) → Caffeine(@CacheEvict)
为什么 L1 用 Caffeine 而不是直接用 Redis?
- Caffeine 是进程内缓存,网络开销为 0,读取 < 1μs
- Redis 即使本地部署也有 0.1-0.5ms 的网络 RTT
- Caffeine 承担了 85% 的读请求,极大降低了 Redis 的负载
3.2 Redis 数据结构应用场景
除了 String 做缓存,Redis 还有其他四种结构在业务中有天然应用:
| 结构 | 电商场景 | 命令示例 |
|---|---|---|
| String | 商品缓存、库存计数 | SET/GET/INCR |
| Hash | 用户购物车(field=商品ID, value=数量) | HSET cart:U001 P001 2 |
| List | 最新订单通知队列 | LPUSH + LTRIM 保留最近 100 条 |
| Set | 用户标签(去重) | SADD tag:新用户 U001 → SINTER 取交集 |
| Sorted Set | 商品销量排行 | ZADD sales:rank 500 P001 → ZREVRANGE |
| Bitmap | 用户签到 | SETBIT sign:2024-01 1001 1 |
| HyperLogLog | 商品 UV 统计(省内存) | PFADD uv:product:1 U001 |
// 用 Sorted Set 实现实时销量排行榜
public class RankService {
private final StringRedisTemplate redis;
public RankService(StringRedisTemplate redis) { this.redis = redis; }
/** 商品售出一件:更新排行榜 */
public void incrSalesRank(String productId, int increment) {
redis.opsForZSet()
.incrementScore("rank:sales", productId, increment);
}
/** 查询 Top 10 */
public Set<String> top10Sales() {
return redis.opsForZSet()
.reverseRange("rank:sales", 0, 9); // 降序取前 10
}
}
3.3 分布式锁:防超卖的最后一公里
库存扣减不能简单地 DECR——需要先判断库存是否足够,这里有并发窗口:
线程A: GET stock → 100 线程B: GET stock → 100
线程A: 100 >= 2 → DECR → 98 线程B: 100 >= 2 → DECR → 98
结果:卖了 4 件,库存只扣了 2 —— 超卖!
package com.demo.service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class LockService {
private final StringRedisTemplate redis;
public LockService(StringRedisTemplate redis) { this.redis = redis; }
/**
* SET key value NX EX seconds
* NX: 仅当 key 不存在时设置(Not eXists)
* EX: 设置过期时间,防止死锁
*/
public String tryLock(String lockKey, int expireSeconds) {
String lockValue = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue()
.setIfAbsent(lockKey, lockValue, expireSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(ok) ? lockValue : null;
}
/**
* Lua 原子解锁:校验 value → 删除 key
* 为什么用 Lua?因为"校验+删除"是两个操作,非原子会误删别人的锁
*/
public boolean unlock(String lockKey, String lockValue) {
String script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else return 0 end""";
Long result = redis.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue);
return result != null && result == 1;
}
/** 防超卖扣库存 */
public boolean deductStock(String productId, int quantity) {
String lockKey = "lock:stock:" + productId;
String lockVal = tryLock(lockKey, 5);
if (lockVal == null) return false; // 获取锁失败,直接拒绝
try {
String stockStr = redis.opsForValue().get("stock:" + productId);
int stock = Integer.parseInt(stockStr);
if (stock < quantity) return false;
redis.opsForValue().decrement("stock:" + productId, quantity);
return true;
} finally {
unlock(lockKey, lockVal);
}
}
}
生产注意:以上是简单版锁。高并发场景建议用 Redisson 的
RLock,它内置了 Watch Dog 自动续期机制,防止业务执行超时导致锁提前释放。
3.4 缓存穿透 / 击穿 / 雪崩全复盘
┌─ 穿透:查不存在的数据 ─────────────────────────┐
│ 攻击者: GET /product/999999(该 ID 不存在) │
│ 缓存: miss → DB: miss → 缓存: 没存 → ... │
│ 结果: 每次请求都打到 DB │
│ 解法: 缓存空值 "NULL",TTL=30s │
└──────────────────────────────────────────────┘
┌─ 击穿:热点 key 过期 ─────────────────────────┐
│ 秒杀开始 → 商品缓存过期 → 1000 个请求同时到达 │
│ 缓存: miss → 1000 个线程同时查 DB │
│ 结果: DB 瞬间被打爆 │
│ 解法: 加锁,只让一个线程查 DB 并回填缓存 │
└──────────────────────────────────────────────┘
┌─ 雪崩:大量 key 同时过期 ─────────────────────┐
│ 凌晨 0 点统一刷新缓存 → 10000 个 key 同时过期 │
│ 结果: 缓存层瞬间失效,所有请求压到 DB │
│ 解法: TTL + random(0, 60s) 随机抖动 │
└──────────────────────────────────────────────┘
| 问题 | 根因 | 本文解法 | 代码位置 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | "NULL" 空值缓存,TTL 30s |
ProductService.getProduct() L629 |
| 击穿 | 热点 key 过期 | 分布式锁控制回填(仅 1 线程查 DB) | LockService |
| 雪崩 | 批量 key 同时过期 | TTL = 600 + random(0,60) 秒随机抖动 |
ProductService.getProduct() L641 |
3.5 缓存一致性的四种方案
写操作后如何保证缓存与 DB 一致?四种方案对比如下:
方案1: 先删缓存,再写 DB(❌ 有并发风险)
线程A: DEL cache → 线程B: GET cache(miss) → GET DB(旧值) → SET cache(旧值) → 线程A: UPDATE DB(新值)
结果: DB 是新值,缓存是旧值 —— 不一致!
方案2: 先写 DB,再删缓存(✅ 本文采用,Cache Aside)
线程A: UPDATE DB(新值) → DEL cache
线程B: GET cache(miss) → GET DB(新值) → SET cache(新值)
结果: 最终一致。极端情况(A 删缓存之前 B 读了旧值),但概率极低
方案3: 延时双删(✅ 高一致性要求)
1. DEL cache → 清掉旧缓存
2. UPDATE DB → 写新数据
3. sleep(500ms) → 等读请求完成
4. DEL cache → 再次清缓存(防止并发写入脏数据)
方案4: 订阅 MySQL binlog(✅✅ 最终方案)
Canal/Debezium 监听 binlog → 解析变更 → 更新/删除 Redis → 准实时一致
适合核心交易数据,延迟 < 100ms
本文代码采用方案 2 + Scheme 4 异步兜底(见第五节)。
四、MongoDB:灵活数据的正确打开方式
MySQL 用 EAV 表(Entity-Attribute-Value)或 JSON 列存多变属性,查起来很痛苦:
-- MySQL 的 EAV 模式查"黑色 8GB 手机":需要多次 JOIN
SELECT p.* FROM product p
JOIN product_attr a1 ON p.id = a1.product_id AND a1.key='color' AND a1.value='黑色'
JOIN product_attr a2 ON p.id = a2.product_id AND a2.key='ram' AND a2.value='8GB';
-- 查询复杂、可读性差、扩展更痛苦
MongoDB 的文档模型天然适配这种场景:
// ProductDetail.java — 核心:Map<String,String> 让不同品类自由定义属性
// 手机: {"screen":"6.7寸","ram":"8GB"} 衣服: {"size":"XL","material":"纯棉"}
package com.demo.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.List;
import java.util.Map;
@Document(collection = "product_detail")
@CompoundIndex(def = "{'categoryId':1, 'price':1}")
public class ProductDetail {
@Id
private String id;
@Indexed
private Long productId; // 关联 MySQL product.id
private String description; // 富文本详情(HTML)
private List<String> images; // 图片 URL 列表
private Map<String, String> attributes; // ★ 多变属性
private List<Review> reviews; // 嵌套评论
// getters & setters(省略,完整代码见第五节项目)
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public Long getProductId() { return productId; }
public void setProductId(Long id) { this.productId = id; }
public String getDescription() { return description; }
public void setDescription(String d) { this.description = d; }
public List<String> getImages() { return images; }
public void setImages(List<String> i) { this.images = i; }
public Map<String, String> getAttributes() { return attributes; }
public void setAttributes(Map<String, String> a) { this.attributes = a; }
public List<Review> getReviews() { return reviews; }
public void setReviews(List<Review> r) { this.reviews = r; }
public static class Review {
private String userId;
private int rating;
private String comment;
private long createdAt;
public String getUserId() { return userId; }
public void setUserId(String u) { this.userId = u; }
public int getRating() { return rating; }
public void setRating(int r) { this.rating = r; }
public String getComment() { return comment; }
public void setComment(String c) { this.comment = c; }
public long getCreatedAt() { return createdAt; }
public void setCreatedAt(long t) { this.createdAt = t; }
}
}
// ProductDetailRepository.java
package com.demo.repository;
import com.demo.model.ProductDetail;
import org.springframework.data.mongodb.repository.Aggregation;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
import java.util.Optional;
public interface ProductDetailRepository
extends MongoRepository<ProductDetail, String> {
Optional<ProductDetail> findByProductId(Long productId);
List<ProductDetail> findByCategoryId(String categoryId);
/** 聚合管道:分类平均评分 */
@Aggregation(pipeline = {
"{ $match: { categoryId: ?0 } }",
"{ $unwind: '$reviews' }",
"{ $group: { _id: '$categoryId', avgRating: { $avg: '$reviews.rating' } } }"
})
Double avgRatingByCategory(String categoryId);
}
五、完整项目:三者协同落地
Spring Boot 3.2.5 · JDK 17 · MySQL 8.0 · Redis 7 · MongoDB 7
5.1 项目结构
db-cache-demo/
├── pom.xml
├── src/main/java/com/demo/
│ ├── DbCacheApplication.java # 启动类
│ ├── entity/Product.java # MySQL 实体
│ ├── model/ProductDetail.java # MongoDB 文档
│ ├── repository/
│ │ ├── ProductRepository.java # MySQL JPA
│ │ └── ProductDetailRepository.java # MongoDB
│ ├── service/
│ │ ├── ProductService.java # 核心:三级缓存
│ │ └── LockService.java # 分布式锁
│ └── controller/ProductController.java
└── src/main/resources/application.yml
pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<groupId>com.demo</groupId>
<artifactId>db-cache-demo</artifactId>
<version>1.0.0</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
</project>
application.yml:
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/shop?useSSL=false&characterEncoding=utf8mb4
username: root
password: root
hikari:
maximum-pool-size: 20
minimum-idle: 5
jpa:
hibernate:
ddl-auto: update
show-sql: false
data:
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4
mongodb:
uri: mongodb://localhost:27017/shop
cache:
type: caffeine
caffeine:
spec: maximumSize=500,expireAfterWrite=60s
启动类:
package com.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class DbCacheApplication {
public static void main(String[] args) {
SpringApplication.run(DbCacheApplication.class, args);
}
}
MySQL 实体:
package com.demo.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "product", indexes = {
@Index(name = "idx_cat_price", columnList = "categoryId,price"),
@Index(name = "idx_cat_sales_cover", columnList = "categoryId,sales,status,title,price")
})
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
private Integer categoryId;
@Column(precision = 10, scale = 2)
private BigDecimal price;
private Integer stock;
private Integer sales;
private Integer status = 1;
private LocalDateTime createdAt = LocalDateTime.now();
private LocalDateTime updatedAt = LocalDateTime.now();
public Product() {}
public Product(String title, Integer categoryId, BigDecimal price, Integer stock) {
this.title = title; this.categoryId = categoryId;
this.price = price; this.stock = stock;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String t) { this.title = t; }
public Integer getCategoryId() { return categoryId; }
public void setCategoryId(Integer c) { this.categoryId = c; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal p) { this.price = p; }
public Integer getStock() { return stock; }
public void setStock(Integer s) { this.stock = s; }
public Integer getSales() { return sales; }
public void setSales(Integer s) { this.sales = s; }
public Integer getStatus() { return status; }
public void setStatus(Integer s) { this.status = s; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime t) { this.createdAt = t; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime t) { this.updatedAt = t; }
}
MySQL Repository:
package com.demo.repository;
import com.demo.entity.Product;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByCategoryIdAndPriceLessThan(Integer categoryId, BigDecimal price);
List<Product> findByCategoryIdOrderBySalesDesc(Integer categoryId);
List<Product> findByStatusOrderBySalesDesc(Integer status, Pageable pageable);
}
5.2 核心 Service(三级缓存完整实现)
package com.demo.service;
import com.demo.entity.Product;
import com.demo.model.ProductDetail;
import com.demo.repository.ProductDetailRepository;
import com.demo.repository.ProductRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Service
public class ProductService {
private final ProductRepository productRepo;
private final ProductDetailRepository detailRepo;
private final StringRedisTemplate redis;
private final ObjectMapper mapper = new ObjectMapper();
public ProductService(ProductRepository productRepo,
ProductDetailRepository detailRepo,
StringRedisTemplate redis) {
this.productRepo = productRepo;
this.detailRepo = detailRepo;
this.redis = redis;
}
// ==================== MySQL 精确查询 ====================
public List<Product> searchByCategory(Integer categoryId, BigDecimal maxPrice) {
return productRepo.findByCategoryIdAndPriceLessThan(categoryId, maxPrice);
}
// ==================== 三级缓存 ====================
/**
* L1: Caffeine(@Cacheable, 1min, 命中率 ~85%)
* L2: Redis(10min + 随机抖动防雪崩, 命中率 ~95%)
* L3: MySQL
*/
@Cacheable(value = "product", key = "#productId")
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// L2: Redis
String cached = redis.opsForValue().get(cacheKey);
if (cached != null) {
if ("NULL".equals(cached)) return null; // 空值防穿透
return parseProduct(cached); // 命中 Redis
}
// L3: MySQL
Optional<Product> opt = productRepo.findById(productId);
if (opt.isEmpty()) {
redis.opsForValue().set(cacheKey, "NULL", 30, TimeUnit.SECONDS);
return null;
}
Product product = opt.get();
int ttl = 600 + (int) (Math.random() * 60); // 随机抖
redis.opsForValue().set(cacheKey, toJson(product), ttl, TimeUnit.SECONDS);
return product;
}
/** 写操作:Cache Aside — 先写 DB,删 Redis + 清 Caffeine */
@CacheEvict(value = "product", key = "#product.id")
public Product saveProduct(Product product) {
Product saved = productRepo.save(product);
redis.delete("product:" + saved.getId()); // 删 Redis
return saved;
}
/** Redis 原子递增(定时任务批量同步到 MySQL) */
public void incrementView(Long productId) {
redis.opsForValue().increment("pv:" + productId);
}
// ==================== MongoDB ====================
public ProductDetail getDetail(Long productId) {
return detailRepo.findByProductId(productId).orElse(null);
}
public Double avgRating(String categoryId) {
return detailRepo.avgRatingByCategory(categoryId);
}
// ==================== 序列化工具 ====================
private String toJson(Object obj) {
try { return mapper.writeValueAsString(obj); }
catch (JsonProcessingException e) { return null; }
}
private Product parseProduct(String json) {
try { return mapper.readValue(json, Product.class); }
catch (JsonProcessingException e) { return null; }
}
}
REST 控制器:
package com.demo.controller;
import com.demo.entity.Product;
import com.demo.model.ProductDetail;
import com.demo.service.ProductService;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
@RestController
@RequestMapping("/api")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/products")
public List<Product> search(@RequestParam Integer categoryId,
@RequestParam(defaultValue = "999999") BigDecimal maxPrice) {
return productService.searchByCategory(categoryId, maxPrice);
}
@GetMapping("/product/{id}")
public Product getProduct(@PathVariable Long id) {
return productService.getProduct(id);
}
@PostMapping("/product")
public Product save(@RequestBody Product product) {
return productService.saveProduct(product);
}
@GetMapping("/product/{id}/detail")
public ProductDetail getDetail(@PathVariable Long id) {
return productService.getDetail(id);
}
@PostMapping("/product/{id}/view")
public void view(@PathVariable Long id) {
productService.incrementView(id);
}
}
5.3 启动与验证
mvn spring-boot:run
MySQL 初始化:
INSERT INTO product (title, category_id, price, stock, sales, status) VALUES
('iPhone 15', 1, 5999.00, 100, 500, 1),
('华为 Mate 60', 1, 4999.00, 80, 300, 1),
('AirPods Pro', 2, 1899.00, 200, 1200, 1),
('MacBook Pro', 1, 12999.00, 50, 100, 1);
MongoDB 初始化:
db.product_detail.insertOne({
productId: 1, categoryId: "1", price: 5999,
description: "<h3>A17 Pro 芯片</h3><p>超强性能...</p>",
images: ["/img/iphone15-1.jpg", "/img/iphone15-2.jpg"],
attributes: { "screen": "6.1寸", "ram": "8GB", "color": "黑色", "storage": "256GB" },
reviews: [
{ userId: "U001", rating: 5, comment: "续航很强", createdAt: 1700000000000 },
{ userId: "U002", rating: 4, comment: "价格偏贵", createdAt: 1700000001000 }
]
});
验证:
# 1. MySQL 索引查询 → 首次 Redis miss,查 MySQL
curl http://localhost:8080/api/product/1
# 2. 再次查询 → Caffeine 缓存命中(1min 内)
curl http://localhost:8080/api/product/1
# 3. MongoDB 商品详情(富文本 + 多变属性 + 评论)
curl http://localhost:8080/api/product/1/detail
# 4. 分类筛选(走联合索引 idx_cat_price)
curl "http://localhost:8080/api/products?categoryId=1&maxPrice=10000"
六、生产环境 Checklist
上线之前,逐条确认:
| # | 检查项 | 说明 |
|---|---|---|
| 1 | MySQL 所有业务 SQL 执行 EXPLAIN | type 不为 ALL,key 不为 NULL |
| 2 | 慢查询日志已开启 | long_query_time = 0.1,定期用 pt-query-digest 分析 |
| 3 | Redis 所有 key 设了 TTL | 无 TTL 的 key 是内存泄漏 |
| 4 | Redis maxmemory-policy = allkeys-lru | 内存满了按 LRU 淘汰,不要用默认的 noeviction |
| 5 | 分布式锁有 Watch Dog | 防止业务超时锁被释放 → 推荐 Redisson |
| 6 | 热点 key 做了多级缓存 | Caffeine L1 扛住 85%+ 流量 |
| 7 | MongoDB 数组字段有上限控制 | 单文档不能超过 16MB |
| 8 | 缓存一致性:写操作后删缓存 | @CacheEvict + redis.delete() |
| 9 | 数据库连接池有泄漏检测 | HikariCP leak-detection-threshold |
| 10 | 监控告警已覆盖 | Redis 内存/命中率、MySQL 慢查询、MongoDB 连接数 |
七、总结:一张图记住全部
┌─────────────────────────────────────────────────────────┐
│ 数据存储选型速查 │
├──────────────┬──────────────────┬───────────────────────┤
│ MySQL │ Redis │ MongoDB │
│ (关系型) │ (内存缓存) │ (文档型) │
├──────────────┼──────────────────┼───────────────────────┤
│ 管: 事务+精确 │ 管: 速度+并发 │ 管: 灵活+扩展 │
│ 索引优化 │ 三级缓存L1→L2→L3 │ Map存多变属性 │
│ EXPLAIN调优 │ 分布式锁防超卖 │ 嵌套文档一次查 │
│ HikariCP池 │ 穿透/击穿/雪崩 │ 聚合管道算评分 │
├──────────────┴──────────────────┴───────────────────────┤
│ 核心原则: 按数据的"形状"选存储,不是所有数据都该放 │
│ 同一张表里。结构固定的进 MySQL,热数据进 Redis, │
│ Schema 多变的进 MongoDB,全文搜索进 Elasticsearch。 │
└─────────────────────────────────────────────────────────┘
> **运行环境**:JDK 17+ · MySQL 8.0 · Redis 7 · MongoDB 7 · Maven 3.8+
>
> **参考资源**:[MySQL 索引优化官方文档](https://dev.mysql.com/doc/refman/8.0/en/optimization-indexes.html) · [Redis 缓存设计模式](https://redis.io/docs/manual/patterns/) · [MongoDB 聚合管道](https://www.mongodb.com/docs/manual/aggregation/) · [Redisson 分布式锁](https://redisson.org/) · [Canal 数据同步](https://github.com/alibaba/canal)
更多推荐


所有评论(0)