摘要:后端性能瓶颈十有八九出在数据库。本文不是教科书式的概念堆砌——而是一个电商系统从"卡到崩溃"到"丝滑流畅"的真实优化路径。MySQL 索引调优、Redis 三级缓存架构、MongoDB 文档模型设计。读完你会有一个清晰的判断:什么场景该用什么存储,以及怎么把它用好。


目录


一、从 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:新用户 U001SINTER 取交集
Sorted Set 商品销量排行 ZADD sales:rank 500 P001ZREVRANGE
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 不为 ALLkey 不为 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)

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐