数据库架构演进之路:从单机ACID到分布式实践
去年年底,我们的电商系统在双十一期间差点崩了。单机MySQL扛不住了,订单表都快到亿级了。紧急扩容、分库分表、上分布式事务…那段时间天天加班到凌晨。现在回过头来看,这次架构升级让我对数据库的理解上了一个台阶。
今天就聊聊这个过程中的一些关键技术点,希望能帮到正在做类似改造的朋友们。
ACID特性:看似简单却处处是坑
ACID这四个字母,面试必考,但真正在生产环境中把握好这些特性,没那么容易。
一个真实的转账案例
先说个血泪史。刚工作那会儿,写了个转账功能,当时想得很简单:
-- 我当时的写法(错误示范)
UPDATE account SET balance = balance - 100 WHERE user_id = 'A';
UPDATE account SET balance = balance + 100 WHERE user_id = 'B';
测试环境一切正常,上线第二天,财务就找上门了——账不平了!有的用户钱扣了,但对方没收到。
后来才知道,这两条SQL虽然写在一起,但如果第一条执行成功,第二条失败了,钱就凭空消失了。这就是没有保证原子性(Atomicity)的后果。
ACID在不同场景的权衡
经过这些年的实践,我总结了不同业务场景下对ACID的需求:
业务场景 | 原子性 | 一致性 | 隔离性 | 持久性 | 典型做法 |
---|---|---|---|---|---|
金融转账 | 必须 | 必须 | 高要求 | 必须 | 使用事务+行锁 |
日志记录 | 可放宽 | 可放宽 | 低要求 | 可异步 | 批量写入+异步持久化 |
购物车 | 一般 | 最终一致 | 一般 | 可恢复 | Redis缓存+定期同步 |
库存扣减 | 必须 | 必须 | 串行化 | 必须 | 分布式锁+事务 |
用户session | 不需要 | 最终一致 | 不需要 | 可丢失 | 纯内存存储 |
隔离级别的选择
说到隔离性,这是最容易出问题的地方。MySQL默认的REPEATABLE READ级别,在大部分场景够用,但也有坑:
-- Session A
START TRANSACTION;
SELECT * FROM product WHERE id = 1; -- 库存100
-- Session B
UPDATE product SET stock = 50 WHERE id = 1;
COMMIT;
-- Session A
SELECT * FROM product WHERE id = 1; -- 还是100!
UPDATE product SET stock = stock - 10 WHERE id = 1; -- 基于100来减
COMMIT; -- 最终库存90,而不是40!
这就是幻读问题。后来我们对库存这类关键数据,要么用SELECT … FOR UPDATE加锁,要么直接用乐观锁。
索引优化:从全表扫描到毫秒响应
索引优化是提升数据库性能最直接的方式。但加索引也是个技术活,加多了写入慢,加少了查询慢。
一次惨痛的教训
有个订单查询接口,随着数据量增长越来越慢。我看了下执行计划:
EXPLAIN SELECT * FROM orders
WHERE user_id = 123
AND status = 1
AND create_time > '2024-01-01';
-- type: ALL, rows: 9876543(全表扫描!)
当时脑子一热,给三个字段都加了索引:
- idx_user_id
- idx_status
- idx_create_time
结果查询是快了,但下单接口变慢了!因为每次插入都要维护三个索引。
索引设计的经验总结
后来我总结了一套索引设计原则:
索引类型 | 适用场景 | 注意事项 | 实例 |
---|---|---|---|
主键索引 | 唯一标识 | 自增ID vs UUID | 订单号、用户ID |
唯一索引 | 业务唯一 | 注意NULL值 | 手机号、邮箱 |
普通索引 | 高频查询 | 选择性要好 | 状态、类型 |
联合索引 | 多条件查询 | 注意顺序 | (user_id, status, time) |
覆盖索引 | 避免回表 | 索引包含所需字段 | (product_id, price, stock) |
前缀索引 | 长字符串 | 平衡长度和选择性 | email(20) |
最后那个订单查询,我改成了一个联合索引:
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
查询时间从30秒降到了0.05秒。
索引优化的小技巧
-
最左前缀原则:联合索引(a,b,c)可以用于a、ab、abc的查询,但不能用于b、c、bc的查询
-
索引下推:MySQL 5.6后的优化,减少回表
-- 假设有索引(name, age)
SELECT * FROM users WHERE name LIKE '张%' AND age > 20;
-- 以前:先用name筛选,回表后再过滤age
-- 现在:在索引中就过滤age,减少回表次数
- 避免索引失效:
- 不要在索引列上做函数操作
- 类型不匹配会导致索引失效
- like '%xxx’无法使用索引
分布式事务:理想很丰满,现实很骨感
当数据库做了分库分表后,分布式事务就成了必须面对的问题。
从两阶段提交说起
最开始,我们用了经典的两阶段提交(2PC):
协调者 参与者A 参与者B
|------- prepare ------->| |
| |<------ yes --------|
|------- prepare -------------------------->|
|<--------------- yes -----------------------|
|------- commit -------->| |
|------- commit ------------------------------>|
理论很美好,但实际问题不少:
- 协调者单点故障
- 阻塞严重,性能差
- 数据不一致的边界情况
实战中的方案选择
根据不同场景,我们采用了不同的方案:
场景 | 方案 | 优点 | 缺点 | 适用性 |
---|---|---|---|---|
订单支付 | Saga模式 | 性能好,可补偿 | 实现复杂 | 长事务 |
库存扣减 | TCC | 强一致 | 侵入性强 | 关键业务 |
消息发送 | 本地消息表 | 简单可靠 | 有延迟 | 异步场景 |
数据同步 | 最终一致 | 性能最好 | 可能不一致 | 非关键业务 |
一个实际的TCC案例
下单扣库存的TCC实现:
// Try阶段:预留资源
public boolean tryReduceStock(String productId, int count) {
// UPDATE stock SET frozen = frozen + ? WHERE product_id = ? AND available >= ?
return stockMapper.freezeStock(productId, count) > 0;
}
// Confirm阶段:确认扣减
public boolean confirmReduceStock(String productId, int count) {
// UPDATE stock SET frozen = frozen - ?, available = available - ? WHERE product_id = ?
return stockMapper.confirmReduce(productId, count) > 0;
}
// Cancel阶段:释放资源
public boolean cancelReduceStock(String productId, int count) {
// UPDATE stock SET frozen = frozen - ? WHERE product_id = ?
return stockMapper.unfreezeStock(productId, count) > 0;
}
这种方案虽然复杂,但保证了库存的准确性。
数据分片:规模化的必经之路
当单表数据量超过千万,查询性能就会明显下降。分片(Sharding)是解决大数据量的有效手段。
分片策略的选择
不同的分片策略适用于不同场景:
分片策略 | 算法 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
范围分片 | 按ID范围 | 易扩展 | 热点问题 | 日志、历史数据 |
哈希分片 | 取模/一致性哈希 | 均匀分布 | 扩容麻烦 | 用户数据 |
地理分片 | 按地区 | 本地化访问快 | 跨区查询慢 | 区域性业务 |
时间分片 | 按月/季度 | 方便归档 | 跨期查询复杂 | 订单、交易记录 |
我们的分片实践
用户表按ID取模分片:
// 简单取模(扩容困难)
int shardId = userId.hashCode() % 16;
// 一致性哈希(便于扩容)
String virtualNode = getVirtualNode(userId);
int shardId = getPhysicalShard(virtualNode);
订单表按时间+用户ID分片:
-- 2024年1月的订单,用户ID尾号0-4的在shard_202401_1
CREATE TABLE order_202401_1 (...);
-- 用户ID尾号5-9的在shard_202401_2
CREATE TABLE order_202401_2 (...);
分片后的挑战
分片解决了大数据量问题,但也带来新挑战:
-
跨片查询:比如按商品名搜索所有订单
- 解决:建立ES索引,先查ID再路由
-
分页问题:跨片分页特别麻烦
- 解决:限制只能查最近N页,深分页用其他方案
-
事务问题:跨片事务复杂
- 解决:尽量设计成单片事务,实在不行用分布式事务
-
数据迁移:重新分片时的数据迁移
- 解决:双写+数据校验+灰度切换
架构演进的一些思考
经历了从单机到分布式的演进,我最大的感受是:没有银弹,只有权衡。
- 不是所有业务都需要强一致性
- 不是所有表都需要分片
- 不是所有查询都需要实时
架构设计要根据业务特点来,过度设计和设计不足都是坑。就像我们的系统,核心交易用TCC保证一致性,日志查询用最终一致性,购物车用纯缓存。不同的业务用不同的方案,这样才能在性能和一致性之间找到平衡。
最后分享一个数据:经过这次改造,我们的系统QPS从5000提升到了50000,数据库响应时间从平均200ms降到了20ms。虽然过程很痛苦,但结果是值得的。
- 点赞
- 收藏
- 关注作者
评论(0)