从单机ACID到分布式实践

举报
i-WIFI 发表于 2025/07/01 20:14:38 2025/07/01
【摘要】 去年双十一,我们差点翻车了。凌晨三点,手机疯狂震动,监控告警一条接一条。爬起来一看,订单库快撑爆了,单表数据逼近一亿,查询直接超时。那一刻真的慌了,赶紧叫醒团队,开始了长达一个月的架构改造。现在想想,那次"事故"反而是好事,逼着我们把技术债还清了。今天就聊聊这次改造的一些心得,都是血泪换来的经验。 ACID特性:看着简单,坑是真的多面试的时候,ACID张口就来。可真到生产环境,才发现自己太天...

去年双十一,我们差点翻车了。
凌晨三点,手机疯狂震动,监控告警一条接一条。爬起来一看,订单库快撑爆了,单表数据逼近一亿,查询直接超时。那一刻真的慌了,赶紧叫醒团队,开始了长达一个月的架构改造。

现在想想,那次"事故"反而是好事,逼着我们把技术债还清了。今天就聊聊这次改造的一些心得,都是血泪换来的经验。

ACID特性:看着简单,坑是真的多

面试的时候,ACID张口就来。可真到生产环境,才发现自己太天真了。

一个让我记一辈子的bug

刚毕业那会儿,接到个转账功能的需求。当时觉得挺简单啊,不就是A减钱B加钱嘛:

-- 我当时的写法(现在看来简直是灾难)
UPDATE account SET balance = balance - 100 WHERE user_id = 'A';
UPDATE account SET balance = balance + 100 WHERE user_id = 'B';

本地测试完美运行,信心满满地上线了。结果呢?第二天财务小姐姐怒气冲冲地找来了:“小王啊,咱们账对不上了!”

一查日志,傻眼了。有些转账,钱扣了,但对方没收到。钱呢?凭空消失了!

这时候才明白,两条SQL语句之间,如果第二条失败了,第一条可不会自动回滚啊。这就是原子性(Atomicity)的重要性——要么全成功,要么全失败,不能搞一半。

不同业务,ACID要求真不一样

踩了几年坑之后,我发现不能一刀切,得看具体业务:

业务场景 原子性 一致性 隔离性 持久性 我们咋搞的
转账付款 必须滴 肯定要 必须高 当然要 老老实实用事务
操作日志 随便点 差不多就行 要啥自行车 晚点再说 批量写,丢几条无所谓
购物车 还行吧 最终对就行 一般般 能恢复就行 Redis存着,定期同步
扣库存 必须滴 必须滴 得串行 那必须的 上锁!各种锁!
登录态 不重要 爱咋咋地 无所谓 丢了再登录呗 纯内存,重启就没

隔离级别这个坑,我跳进去过

MySQL默认的REPEATABLE READ,大部分时候OK,但碰到库存扣减这种场景,坑就来了:

-- 线程A
START TRANSACTION;
SELECT * FROM product WHERE id = 1; -- 看到库存100

-- 这时候线程B进来了
UPDATE product SET stock = 50 WHERE id = 1;
COMMIT;

-- 线程A继续
SELECT * FROM product WHERE id = 1; -- 竟然还是100!
UPDATE product SET stock = stock - 10 WHERE id = 1; -- 以为是100-10
COMMIT; -- 完蛋,库存变成90了,不是40!

第一次遇到这问题的时候,我怀疑人生了。后来学乖了,库存这种东西,老老实实加锁:

SELECT * FROM product WHERE id = 1 FOR UPDATE;

或者干脆用乐观锁,带个版本号,更新的时候check一下。

索引优化:速度与激情的平衡术

都说索引是查询优化的银弹,但用不好就是毒药。

一个让我加班到凌晨的优化

有个订单查询接口,刚上线还行,半年后慢得像蜗牛。用EXPLAIN一看:

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(时间范围查询)

查询确实快了,可是…下单接口开始卡了!每次INSERT都要更新三个索引树,能不慢吗?

索引设计的血泪教训

现在我建索引都很谨慎,总结了几条原则:

索引类型 啥时候用 特别注意 活生生的例子
主键 每张表都得有 自增ID简单粗暴 order_id, user_id
唯一索引 业务上不能重复的 NULL是个坑 手机号(但换号咋办?)
普通索引 查询频繁的字段 区分度要高 status(就几个值,白搭)
联合索引 多条件查询 顺序很重要! (user_id,status,time)
覆盖索引 不想回表 字段别太多 (id,price,stock)够用就行
前缀索引 字符串太长 找好平衡点 url(50)差不多了

最后那个慢查询,我建了个联合索引搞定:

CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);

30秒变0.05秒,爽!

索引使用的一些坑

  1. 最左前缀:(a,b,c)的索引,查b、c用不上,白瞎

  2. 函数操作WHERE DATE(create_time) = '2024-01-01',索引废了

  3. 类型转换WHERE phone = 13812345678,phone是varchar,索引又废了

  4. LIKE查询LIKE '%keyword%',最左边有%,索引还是废了

分布式事务:理论很美好,现实很打脸

单机玩不转了,只能上分布式。分布式事务这玩意儿,听着高大上,做起来想骂人。

两阶段提交,理想很丰满

教科书上的2PC看着挺美:

协调者:"大家准备好了吗?"
参与者A"OK的"  
参与者B"我也OK"
协调者:"那都提交吧!"
大家:"收到!"

实际上呢?

  • 协调者挂了咋办?大家干等着?
  • 网络抖了一下,消息丢了咋办?
  • 第一阶段都说OK,第二阶段有人反悔了咋办?

因地制宜的解决方案

折腾了很久,我们最后是这么干的:

业务场景 方案 为啥选它 吐槽
下单付款 Saga 能补偿就行 写补偿逻辑写到吐
扣库存 TCC 不能超卖啊 代码侵入太狠了
发消息 本地消息表 简单靠谱 有延迟,但能接受
数据同步 最终一致 性能第一 偶尔不一致,忍了

TCC的实战代码

扣库存这块,我们用的TCC,代码大概长这样:

// Try:先冻结
public boolean tryReduceStock(String productId, int count) {
    // 有货就冻结,没货拉倒
    return stockMapper.freezeStock(productId, count) > 0;
}

// Confirm:真扣了
public boolean confirmReduceStock(String productId, int count) {
    // 冻结的扣掉
    return stockMapper.confirmReduce(productId, count) > 0;
}

// Cancel:不买了,解冻
public boolean cancelReduceStock(String productId, int count) {
    // 冻结的加回去
    return stockMapper.unfreezeStock(productId, count) > 0;
}

麻烦是麻烦了点,但不会超卖,老板放心。

数据分片:不分不行啊

单表过亿,再怎么优化都没用了,只能分片。

分片策略,各有各的坑

试了好几种分片方式:

分片方式 咋分的 好处 坑在哪 适合啥
范围分 按ID段 好扩容 热点啊!新数据都在一片 日志表还行
哈希分 取模 分得均匀 扩容要死人 用户表可以
地理分 按地区 本地快 跨区咋办 地域性强的业务
时间分 按月份 好归档 跨月查询烦死 订单、流水

我们的分片实践

用户表,简单粗暴取模:

// 刚开始这么干的
int shardId = userId.hashCode() % 16;

// 后来学聪明了,用一致性哈希
String virtualNode = hash(userId);
int shardId = routeTable.get(virtualNode);

订单表,时间+用户双维度:

-- 202401月份的订单再按用户分
CREATE TABLE order_202401_01 (...); -- 用户ID尾号0-4
CREATE TABLE order_202401_02 (...); -- 用户ID尾号5-9

分片后的新麻烦

分是分了,新问题又来了:

  1. 跨片查询:想查所有订单?遍历所有分片?告辞!

    • 我们的解法:上ES,先查ID再路由
  2. 分页:第100页的数据在哪个分片上?鬼知道!

    • 我们的解法:只让翻前10页,再往后让他用筛选
  3. 事务:跨片事务,参见上面的分布式事务,哭

    • 我们的解法:设计上尽量避免,实在不行就TCC
  4. 扩容:16个分片不够了,扩到32个,数据迁移想死

    • 我们的解法:一致性哈希+灰度迁移,折腾了仨月

最后的一点感悟

这次架构升级,技术上确实学到很多,但更重要的是几个认识:

第一,过度设计要不得。 我们刚开始想一步到位,搞个完美架构。结果呢?复杂度爆炸,bug一堆。后来老老实实一步步来,先解决最痛的点。

第二,监控比什么都重要。 很多问题不是技术不行,是发现太晚。现在我们各种监控加满,稍有异常立马知道。

第三,选型要结合业务。 别人的方案再好,不适合你也白搭。比如我们的积分系统,数据量不大,搞那么复杂干啥?单机MySQL跑得好好的。

说了这么多,其实就一句话:架构是演进出来的,不是设计出来的。踩坑不可怕,怕的是踩了白踩。

对了,经过这次改造,系统确实猛了很多。QPS从5千干到了5万,响应时间从200ms降到20ms。虽然过程很痛苦(头发掉了不少),但看到监控上的绿色曲线,还是挺有成就感的。

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

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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