从单机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秒,爽!
索引使用的一些坑
-
最左前缀:(a,b,c)的索引,查b、c用不上,白瞎
-
函数操作:
WHERE DATE(create_time) = '2024-01-01'
,索引废了 -
类型转换:
WHERE phone = 13812345678
,phone是varchar,索引又废了 -
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
分片后的新麻烦
分是分了,新问题又来了:
-
跨片查询:想查所有订单?遍历所有分片?告辞!
- 我们的解法:上ES,先查ID再路由
-
分页:第100页的数据在哪个分片上?鬼知道!
- 我们的解法:只让翻前10页,再往后让他用筛选
-
事务:跨片事务,参见上面的分布式事务,哭
- 我们的解法:设计上尽量避免,实在不行就TCC
-
扩容:16个分片不够了,扩到32个,数据迁移想死
- 我们的解法:一致性哈希+灰度迁移,折腾了仨月
最后的一点感悟
这次架构升级,技术上确实学到很多,但更重要的是几个认识:
第一,过度设计要不得。 我们刚开始想一步到位,搞个完美架构。结果呢?复杂度爆炸,bug一堆。后来老老实实一步步来,先解决最痛的点。
第二,监控比什么都重要。 很多问题不是技术不行,是发现太晚。现在我们各种监控加满,稍有异常立马知道。
第三,选型要结合业务。 别人的方案再好,不适合你也白搭。比如我们的积分系统,数据量不大,搞那么复杂干啥?单机MySQL跑得好好的。
说了这么多,其实就一句话:架构是演进出来的,不是设计出来的。踩坑不可怕,怕的是踩了白踩。
对了,经过这次改造,系统确实猛了很多。QPS从5千干到了5万,响应时间从200ms降到20ms。虽然过程很痛苦(头发掉了不少),但看到监控上的绿色曲线,还是挺有成就感的。
- 点赞
- 收藏
- 关注作者
评论(0)