数据库架构演进之路:从单机ACID到分布式实践

举报
i-WIFI 发表于 2025/07/01 20:02:40 2025/07/01
【摘要】 去年年底,我们的电商系统在双十一期间差点崩了。单机MySQL扛不住了,订单表都快到亿级了。紧急扩容、分库分表、上分布式事务…那段时间天天加班到凌晨。现在回过头来看,这次架构升级让我对数据库的理解上了一个台阶。今天就聊聊这个过程中的一些关键技术点,希望能帮到正在做类似改造的朋友们。 ACID特性:看似简单却处处是坑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秒。

索引优化的小技巧

  1. 最左前缀原则:联合索引(a,b,c)可以用于a、ab、abc的查询,但不能用于b、c、bc的查询

  2. 索引下推:MySQL 5.6后的优化,减少回表

-- 假设有索引(name, age)
SELECT * FROM users WHERE name LIKE '张%' AND age > 20;
-- 以前:先用name筛选,回表后再过滤age
-- 现在:在索引中就过滤age,减少回表次数
  1. 避免索引失效
  • 不要在索引列上做函数操作
  • 类型不匹配会导致索引失效
  • 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 (...);

分片后的挑战

分片解决了大数据量问题,但也带来新挑战:

  1. 跨片查询:比如按商品名搜索所有订单

    • 解决:建立ES索引,先查ID再路由
  2. 分页问题:跨片分页特别麻烦

    • 解决:限制只能查最近N页,深分页用其他方案
  3. 事务问题:跨片事务复杂

    • 解决:尽量设计成单片事务,实在不行用分布式事务
  4. 数据迁移:重新分片时的数据迁移

    • 解决:双写+数据校验+灰度切换

架构演进的一些思考

经历了从单机到分布式的演进,我最大的感受是:没有银弹,只有权衡

  • 不是所有业务都需要强一致性
  • 不是所有表都需要分片
  • 不是所有查询都需要实时

架构设计要根据业务特点来,过度设计和设计不足都是坑。就像我们的系统,核心交易用TCC保证一致性,日志查询用最终一致性,购物车用纯缓存。不同的业务用不同的方案,这样才能在性能和一致性之间找到平衡。

最后分享一个数据:经过这次改造,我们的系统QPS从5000提升到了50000,数据库响应时间从平均200ms降到了20ms。虽然过程很痛苦,但结果是值得的。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。