记得那是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万时才考虑。而且要做好数据迁移的准备,这个过程风险很大。
写在最后
回头看,数据库性能问题从来不是单一技术能解决的,它需要架构设计、代码优化、监控预警、运维保障多方面的配合。我们踩过坑,交过学费,但也积累了宝贵的经验。
最重要的是要有预判能力。不要等到系统崩了再临时抱佛脚,要在设计阶段就考虑高并发场景,做好容量规划和压力测试。我们后来引入了全链路压测,每次大促前都会模拟真实流量,把问题消灭在萌芽状态。
数据库是系统的命脉
