记得那是2019年的一个深夜,我刚接手一个新项目的数据库维护工作。半夜三点,业务群突然炸了,运营小伙伴带着哭腔说:“后台订单全卡住了,用户点支付转圈转了五分钟还在加载。”我顶着黑眼圈连上服务器,打开监控一看,CPU瞬间飙到100%,MySQL的慢查询日志像瀑布一样刷屏。那一刻我才深刻体会到,数据库性能问题从来不是纸上谈兵的理论,而是真金白银的教训。

从一次崩溃说起:当订单系统遭遇流量洪峰

先说说我的亲身经历。那个项目是一个新兴的电商小程序,初期用户量不多,一台8核32G的服务器跑得挺顺畅。突然有一天,我们搞了一次营销活动,预计带来十万级流量。活动刚开始五分钟,系统就瘫了。

我去排查问题,发现主要是这几个症状:

首先是连接池被打满。MySQL默认的max_connections是151,但我们当时配置了2000个连接,结果这些连接都在等待资源,互相堵塞。

其次是慢查询堆积。一张订单表因为加了三个大字段索引,查询时全表扫描变成主要模式,每笔订单查询都要2-3秒。

最后是磁盘IO瓶颈。因为热数据不断刷新到磁盘,缓冲池命中率跌到了60%以下,系统大部分时间在等IO。

我当时的解决思路特别简单粗暴:加机器、扩容、升级配置。但这样做成本太高,而且治标不治本。后来我冷静下来分析,发现真正的瓶颈在于架构设计从一开始就没有考虑高并发场景。

小公司的生存之道:分库分表实战经验

很多初创公司资源有限,不可能像大厂那样搞复杂的微服务架构。这时候分库分表就是性价比最高的方案。

什么是分库分表?

简单来说,分库就是把一个大数据库拆分成多个独立的小数据库,分散压力。分表则是把一个表的数据拆到多个表里,比如订单表可以从一个表拆成100个子表。

具体怎么操作?

我们以订单系统为例,看看实际怎么落地。

第一步是确定拆分策略。常见的有按用户ID取模、按时间范围、按业务类型等。我们当时用的是按用户ID取模,因为用户维度查询最多,这样能确保同一个用户的数据集中在同一个分片。

-- 假设我们要把订单表拆分成16个分表
-- 创建分表时的表结构
CREATE TABLE orders_00 (
  order_id BIGINT NOT NULL COMMENT '订单ID',
  user_id INT NOT NULL COMMENT '用户ID',
  product_id INT NOT NULL COMMENT '商品ID',
  amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
  status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (order_id),
  INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 同样的方式创建orders_01到orders_15,共16个表
-- 表数量通常是2的幂次,方便位运算取模

第二步是应用层的路由逻辑。我们用的是ShardingSphere框架,配置非常简单:

# sharding.yaml配置示例
dataSources:
  ds_0:
    url: jdbc:mysql://localhost:3306/orders_db_0
    username: root
    password: password
  ds_1:
    url: jdbc:mysql://localhost:3306/orders_db_1
    username: root
    password: password
shardingRule:
  tables:
    orders:
      actualDataNodes: ds_${0..1}.orders_${0..15}
      tableStrategy:
        standard:
          shardingColumn: user_id
          shardingAlgorithmName: user_id_mod_16
  bindingTables:
    - orders
// 取模分片算法实现
public class UserIdMod16ShardingAlgorithm implements ShardingAlgorithm<Integer> {
    @Override
    public String doSharding(Collection<String> availableTargetNames, Collection<ScalarValue<Integer> > shardingValues) {
        for (ScalarValue<Integer> value : shardingValues) {
            int userId = value.getValue();
            // 使用位运算,效率更高
            int index = userId & 0xF; // 对16取模
            for (String tableName : availableTargetNames) {
                if (tableName.endsWith(String.format("%02d", index))) {
                    return Collections.singletonList(tableName);
                }
            }
        }
        throw new IllegalArgumentException("无法找到匹配的分片");
    }
}

第三步是处理跨分片查询。这是最头疼的问题,比如要统计某个时间段所有订单的总额。我们的做法是用ES做补充查询,MySQL只负责单分片的精确查询。

// 跨分片查询的解决方案:使用ES聚合
@PostMapping("/orders/statistics")
public OrderStatisticsVO getStatistics(@RequestBody OrderQueryDTO queryDTO) {
    // 先从ES查询聚合结果
    SearchResponse response = elasticsearchClient.search(
        SearchRequest.of(s -> s
            .index("orders")
            .query(q -> q
                .range(r -> r
                    .field("createTime")
                    .gte(queryDTO.getStartTime())
                    .lte(queryDTO.getEndTime())
                )
            )
            .aggregations("totalAmount", a -> a
                .sum(s -> s.field("amount"))
            )
            .aggregations("orderCount", a -> a
                .valueCount(vc -> vc.field("orderId"))
            )
        )
    );
    
    // 解析ES聚合结果
    double totalAmount = response.aggregations().get("totalAmount").sum().value();
    long orderCount = response.aggregations().get("orderCount").valueCount().value();
    
    return OrderStatisticsVO.builder()
        .totalAmount(totalAmount)
        .orderCount(orderCount)
        .build();
}

实际操作下来,我们发现分库分表最大的坑不是技术实现,而是运维复杂度。原来一个库的备份、迁移、监控,现在要管16个库。我们后来引入了统一的数据库中间件管理平台,把运维成本降了下来。

还有一个容易忽视的问题是数据迁移。我们当时是从单表迁移到分库分表,采用的方式是双写方案:新旧系统同时写入,数据同步完成后切换读流量。这个过程花了两周时间,但保证了业务零中断。

大厂的武器库:读写分离与缓存的神级配合

如果说分库分表是小公司的急救包,那读写分离加缓存就是大厂的常规操作。京东618、双11这些大促场景,数据库压力是平时的几十倍甚至上百倍,这时候单靠分库分表是扛不住的。

读写分离的基本原理

读写分离就是把读操作和写操作分开处理。主库负责写,从库负责读,通过MySQL的binlog机制实现数据同步。

-- 主库配置
[mysqld]
server-id=1
log-bin=mysql-bin
binlog-format=ROW
relay-log=relay-bin
relay-log-recovery=1

-- 从库配置
[mysqld]
server-id=2
log-bin=mysql-bin
relay-log=relay-bin
read-only=1
super-read-only=1
// 读写分离的路由策略实现
@Component
public class DataSourceRouter {
    
    @Autowired
    private MasterDataSource masterDataSource;
    
    @Autowired
    private SlaveDataSource slaveDataSource;
    
    // 使用ThreadLocal区分读写数据源
    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
    
    public static void setMaster() {
        CONTEXT_HOLDER.set("master");
    }
    
    public static void setSlave() {
        CONTEXT_HOLDER.set("slave");
    }
    
    public static String getDataSource() {
        return CONTEXT_HOLDER.get() != null ? CONTEXT_HOLDER.get() : "slave";
    }
    
    @Bean
    @Primary
    public DataSource dataSource() {
        return new DynamicDataSource(masterDataSource, slaveDataSource);
    }
}

// AOP切面自动路由
@Aspect
@Component
public class DataSourceAspect {
    
    @Around("@annotation(com.example.annotation.Master)")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        DataSourceRouter.setMaster();
        try {
            return point.proceed();
        } finally {
            DataSourceRouter.setSlave();
        }
    }
}

缓存架构:多级缓存的实战经验

缓存是解决高并发读压力的利器。我们当时设计的是三级缓存架构:本地缓存(Caffeine)+ Redis集群 + MySQL。

// 本地缓存 + Redis 二级缓存实现
@Component
public class OrderCacheService {
    
    // 本地缓存,TTL 30秒,容量10000
    private final Cache<Long, OrderDTO> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(30, TimeUnit.SECONDS)
        .build();
    
    // Redis缓存,TTL 5分钟
    @Autowired
    private RedisTemplate<String, OrderDTO> redisTemplate;
    
    @Autowired
    private OrderMapper orderMapper;
    
    public OrderDTO getOrder(Long orderId) {
        // 第一层:本地缓存
        OrderDTO order = localCache.getIfPresent(orderId);
        if (order != null) {
            return order;
        }
        
        // 第二层:Redis缓存
        String redisKey = "order:" + orderId;
        order = redisTemplate.opsForValue().get(redisKey);
        if (order != null) {
            localCache.put(orderId, order);
            return order;
        }
        
        // 第三层:数据库查询
        order = orderMapper.selectById(orderId);
        if (order != null) {
            redisTemplate.opsForValue().set(redisKey, order, 5, TimeUnit.MINUTES);
            localCache.put(orderId, order);
        }
        
        return order;
    }
    
    // 缓存更新策略:先删缓存,再更新数据库
    public void updateOrder(OrderDTO order) {
        String redisKey = "order:" + order.getOrderId();
        // 先删除缓存,避免脏数据
        redisTemplate.delete(redisKey);
        localCache.invalidate(order.getOrderId());
        // 再更新数据库
        orderMapper.updateById(order);
    }
}

这里有个很重要的细节:缓存更新顺序。很多人习惯先更新数据库再删缓存,这在分布式环境下会有问题。比如A线程先删缓存,B线程查数据库没命中缓存就去查DB并写缓存,然后A线程才更新数据库,这时候缓存里就是脏数据了。所以正确的做法是先删缓存,再更新数据库。

缓存穿透、击穿、雪崩的解决方案

高并发场景下缓存问题比普通场景复杂得多,我们遇到过好几个典型案例。

缓存穿透是指查询根本不存在的数据,每次请求都会打到数据库。我们的解决方案是布隆过滤器。

@Component
public class BloomFilterService {
    
    private final BloomFilter<Long> bloomFilter;
    
    @PostConstruct
    public void init() {
        // 初始化布隆过滤器,预计插入1000万条订单ID
        bloomFilter = BloomFilter.create(
            Funnels.longFunnel(),
            10_000_000,
            0.001  // 误判率0.1%
        );
        // 将现有订单ID加载到布隆过滤器
        List<Long> orderIds = orderMapper.selectAllIds();
        orderIds.forEach(bloomFilter::put);
    }
    
    public boolean mightContain(Long orderId) {
        return bloomFilter.mightContain(orderId);
    }
    
    public void add(Long orderId) {
        bloomFilter.put(orderId);
    }
}
// 在缓存查询前加入布隆过滤器判断
public OrderDTO getOrder(Long orderId) {
    // 先过布隆过滤器
    if (!bloomFilter.mightContain(orderId)) {
        return null; // 数据肯定不存在,直接返回
    }
    
    // 后续逻辑...
}

缓存击穿是指热点key过期瞬间,大量请求同时打到数据库。我们用的是互斥锁+永不过期策略。

public OrderDTO getOrderWithMutex(Long orderId) {
    OrderDTO order = redisTemplate.opsForValue().get("order:" + orderId);
    if (order != null) {
        return order;
    }
    
    // 使用分布式锁,保证只有一个线程去查数据库
    String lockKey = "lock:order:" + orderId;
    boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 双重检查
            order = redisTemplate.opsForValue().get("order:" + orderId);
            if (order != null) {
                return order;
            }
            
            // 查数据库
            order = orderMapper.selectById(orderId);
            if (order != null) {
                // 设置永不过期,避免击穿
                redisTemplate.opsForValue().set("order:" + orderId, order);
            }
            return order;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 其他线程在重建缓存,短轮询
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getOrderWithMutex(orderId);
    }
}

缓存雪崩是指大量key同时过期,导致数据库压力激增。我们采用随机过期时间+热点key永不过期的组合策略。

// 热点key识别和永不过期处理
@Component
public class HotKeyDetector {
    
    // 记录每个key的访问次数
    private final Map<String, AtomicInteger> accessCount = new ConcurrentHashMap<>();
    
    @Scheduled(fixedRate = 60000) // 每分钟统计一次
    public void detectHotKeys() {
        accessCount.entrySet().removeIf(entry -> {
            if (entry.getValue().get() > 1000) { // 阈值可配置
                // 热点key,设置永不过期
                String key = entry.getKey();
                Object value = redisTemplate.opsForValue().get(key);
                if (value != null) {
                    redisTemplate.opsForValue().set(key, value); // 刷新但不设置过期时间
                }
                return true;
            }
            return false;
        });
    }
    
    public void recordAccess(String key) {
        accessCount.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();
    }
}

从0到1000万:数据库架构演进之路

回顾这几年,我们的数据库架构经历了三个阶段,每个阶段都有对应的痛点和解法。

第一阶段:单体MySQL

项目初期,一张订单表,一个MySQL实例,最多支撑每秒500QPS。这个阶段的问题很简单:数据量大了查询变慢。解决方案就是加索引、优化SQL。

-- 常见的索引优化手段
-- 1. 选择合适的索引列
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, create_time);

-- 2. 覆盖索引避免回表
SELECT order_id, amount FROM orders WHERE user_id = 1001 AND status = 1;

-- 3. 避免SELECT *
-- 错误示范
SELECT * FROM orders WHERE user_id = 1001;
-- 正确示范
SELECT order_id, amount, status FROM orders WHERE user_id = 1001;

第二阶段:分库分表+读写分离

用户量增长到百万级后,单库撑不住了。我们引入了ShardingSphere做分库分表,同时搭建主从复制做读写分离。这个阶段能支撑每秒5000QPS,但运维复杂度急剧上升。

第三阶段:分布式架构

现在我们要支撑每秒5万QPS,架构已经演进成:分库分表+读写分离+多级缓存+消息队列异步化+ES搜索。数据库只负责核心数据的持久化和一致性保证,复杂的查询和统计都下沉到ES和OLAP引擎。

给小公司的实用建议

如果你是小公司,资源有限,我建议按这个顺序逐步演进:

第一步:先把现有系统优化到极致

很多时候问题不是架构不行,而是基础没打好。我们当时花了一周时间做SQL审计,发现30%的慢查询都是因为没有索引或者索引设计不合理。

-- 查看慢查询日志
SHOW VARIABLES LIKE 'slow_query_log%';
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

-- 分析慢查询
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

第二步:引入缓存层

Redis成本不高,一杯咖啡钱就能缓解大部分读压力。我们当时上线Redis后,数据库负载直接下降了70%。

第三步:读写分离

如果读多写少,读写分离是最划算的升级。主库负责写,两到三个从库负责读,成本增加不多,效果明显。

第四步:分库分表

这是最后一步,因为复杂度很高。只有当单库数据量超过5000万,或者QPS超过1万时才考虑。而且要做好数据迁移的准备,这个过程风险很大。

写在最后

回头看,数据库性能问题从来不是单一技术能解决的,它需要架构设计、代码优化、监控预警、运维保障多方面的配合。我们踩过坑,交过学费,但也积累了宝贵的经验。

最重要的是要有预判能力。不要等到系统崩了再临时抱佛脚,要在设计阶段就考虑高并发场景,做好容量规划和压力测试。我们后来引入了全链路压测,每次大促前都会模拟真实流量,把问题消灭在萌芽状态。

数据库是系统的命脉