尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

单库拆成 8 个分片那年,发布窗口从 20 分钟拉到 3 小时:架构演进的 5 笔隐性成本

单库拆成 8 个分片那年,发布窗口从 20 分钟拉到 3 小时:架构演进的 5 笔隐性成本 title: 单库拆成 8 个分片那年发布窗口从 20 分钟拉到 3 小时架构演进的 5 笔隐性成本tags: [架构演进, 分库分表, 微服务, ShardingSphere, 高可用]category: 后端分库分表上线后的第一个大促预演我们的发布流程从 20 分钟膨胀到了 3 小时。不是因为部署慢是因为要人肉核对 8 个分片的 DDL 是否都执行到位——其中一个分片因为连接池打满漏执行了一条ALTER TABLE直到灰度流量打进去报Unknown column settle_status才被发现。那次之后我把过去四年这套系统的演进路径重新捋了一遍从一台 8 核 16G 的机器跑 Tomcat MySQL到现在 60 多个服务、8 个业务库。每一步升级在架构图上都很好看但每一步都附带一笔当时没算进去的账。这篇写的就是这些账。阶段一单机部署瓶颈往往不在你以为的地方最早那版系统很简单一台 ECSTomcat 8.5 MySQL 5.7 装在同一台机器上日订单量三千出头。这个阶段大部分人以为的瓶颈是 CPU实际先撑不住的是文件描述符和磁盘 IO。我们第一次线上告警是Too many open files。Tomcat 的maxConnections默认 10000而系统的ulimit -n是 1024。MySQL 和 Tomcat 抢同一份 fd 配额MySQL 的 InnoDB 又要为每张表保持文件句柄innodb_file_per_tableON双方一起把 1024 吃干净。这个阶段真正该做的不是拆架构是把机器配置和参数调对项目默认值我们调整后说明ulimit -n102465535Tomcat MySQL 共享必须提高innodb_buffer_pool_size128M10G单机 16G给 MySQL 留 60% 左右Tomcat maxThreads200400业务偏 IO 密集线程可以多给MySQL max_connections151500配合 HikariCP 池上限调完这四项同样的机器扛住了日订单一万二。我不建议在日订单量没到五位数的时候就上微服务这个阶段的性能问题九成是参数没调对拆服务只会让你多几个需要调参的地方。阶段二读写分离第一次踩到主从延迟订单量上到日均三万后MySQL 的 CPU 开始在晚高峰打到 85%。分析慢日志发现读写比例大概 8:2于是上了一主两从。读写分离本身不难难的是读到旧数据。我们用的是 Spring 的AbstractRoutingDataSource最初的实现是这样的public class DynamicDataSource extends AbstractRoutingDataSource { // 每个线程独立保存自己的数据源标记避免线程间串数据 private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void useSlave() { CONTEXT.set(slave); } public static void useMaster() { CONTEXT.set(master); } public static void clear() { // 必须清理否则线程池复用线程时会带上上一个请求的标记 CONTEXT.remove(); } Override protected Object determineCurrentLookupKey() { // Spring 每次获取连接前都会调这个方法返回 null 时走 defaultTargetDataSource String key CONTEXT.get(); return key null ? master : key; } }逐行说一下关键点。CONTEXT用ThreadLocal存标记因为 Tomcat 的线程池会复用线程如果不用线程隔离A 请求设置的 slave 标记会被 B 请求读到。clear()这个方法我们第一版忘了写结果一个后台任务把标记设成 slave 之后没清理同一个线程后续处理的下单请求全部路由到了从库写操作直接报The MySQL server is running with the --read-only option。determineCurrentLookupKey()是 Spring 的钩子方法AbstractRoutingDataSource.getConnection()每次都会调用它拿 key再去targetDataSources这个 Map 里找对应的 DataSource——注意它是每次获取连接时调用不是每个事务调用一次所以在一个方法内部切换标记是不生效的事务已经绑定了连接。真正的坑是主从延迟。我们的场景是用户下单成功后立刻跳转订单详情页详情页走从库查询而当时主从延迟平均 80ms晚高峰能到 600ms。用户看到的是订单不存在。最后的解法不是提高从库性能而是给写后读加强制主库标记Aspect Component public class MasterRouteAspect { // 标记了 ForceMaster 的方法强制走主库 Around(annotation(com.xxx.annotation.ForceMaster)) public Object around(ProceedingJoinPoint pjp) throws Throwable { DynamicDataSource.useMaster(); try { return pjp.proceed(); } finally { // 无论成功失败都要清防止污染线程 DynamicDataSource.clear(); } } }这个切面的价值在于把哪些查询必须读主库这件事显式写在代码里而不是靠约定。我们统计过全站 1200 多个查询方法里最终只有 37 个加了ForceMaster占比 3%——大部分读操作其实容忍得了几百毫秒的延迟值得为这 3% 单独处理不值得为它们放弃读写分离。阶段三服务化拆分被低估的是跨服务事务日订单十万级的时候我们做了第一次服务拆分按业务域切成订单、商品、库存、支付、用户五个服务用的是 Spring Cloud AlibabaNacos 做注册中心OpenFeign 做调用。架构图画出来很漂亮代价在第二周就来了原来一个本地事务能搞定的事情现在要跨三个服务。下单流程原本是一个Transactional方法扣库存、写订单、扣优惠券。拆开之后库存在库存服务、优惠券在营销服务本地事务失效。我们第一版用的是先扣库存再写订单失败了调用补偿接口回滚看起来没问题直到某天库存服务的补偿接口自己超时了。那天的具体数字15 分钟内产生了 216 笔库存已扣、订单未生成的脏数据人工对账花了两个工作日。后来改成了本地消息表 定时补偿核心代码是这样Service public class OrderCreateService { Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateCmd cmd) { // 1. 写订单主表此时状态为 INIT对外不可见 Order order Order.init(cmd); orderMapper.insert(order); // 2. 把扣库存这个动作作为一条消息写进本地消息表 // 和订单在同一个事务里要么都成功要么都回滚 LocalMessage msg LocalMessage.of( STOCK_DEDUCT, JSON.toJSONString(new StockDeductCmd(order.getId(), cmd.getSkuId(), cmd.getNum())), order.getId()); localMessageMapper.insert(msg); return order.getId(); } }这里最重要的一行是localMessageMapper.insert(msg)和orderMapper.insert(order)在同一个事务里。这样就把分布式事务降级成了本地事务 可靠投递只要订单落库了扣库存这条消息就一定存在消息发送失败可以重试重试失败可以人工介入但不会出现库存扣了订单没了。投递部分交给一个独立的扫描任务Scheduled(fixedDelay 1000) public void dispatch() { // 只捞 5 秒前创建、状态仍为 PENDING 的消息 // 留 5 秒缓冲是为了避开事务尚未提交、消息已被扫到的竞态 ListLocalMessage list localMessageMapper.selectPending( LocalDateTime.now().minusSeconds(5), 200); for (LocalMessage msg : list) { try { mqProducer.send(msg.getTopic(), msg.getPayload()); localMessageMapper.markSent(msg.getId()); } catch (Exception e) { // 失败不抛出避免一条坏消息卡住整批 localMessageMapper.incrRetry(msg.getId()); log.warn(dispatch failed, msgId{}, retry{}, msg.getId(), msg.getRetryCount(), e); } } }selectPending里的 5 秒缓冲是我们踩出来的。最早写的是now()结果定时任务扫到了一条已插入但事务还没提交的记录MySQL 默认 RR 隔离级别下另一个连接读不到未提交数据但我们当时用的是 READ_COMMITTED 且中间有一次连接切换导致消息发出去了、订单事务却回滚了下游扣了不存在订单的库存。加 5 秒之后这个问题再没出现过。catch块里不抛异常是另一个细节。第一版我们让异常往上抛结果某条 payload 太大超过 RocketMQ 4.9.x 默认的 4MB 限制导致每次批处理都在这条上失败后面 199 条全被堵住堆积了 4 小时。阶段四分库分表DDL 变成了工程问题订单表到 1.8 亿行的时候单表查询即使走索引也要 200ms 以上ALTER TABLE更是不敢碰。我们按user_id哈希拆了 8 个库、每库 16 张表用 ShardingSphere-JDBC 5.1.2。分片规则配置本身两小时就搞定了真正吃掉三个月的是这些问题拆分前拆分后我们的处理按订单号查询直接查不带 user_id 无法路由全库扫订单号里编码 user_id 后 4 位分页查询limit offset需归并 8 库结果改为游标分页禁止深翻页跨用户统计一条 SQL无法直接聚合走离线数仓接受 T1DDL 变更1 张表128 张表自研脚本 执行结果校验事务本地事务跨库不保证强制同一 user_id 内操作开头提到的那次发布事故就出在最后一行 DDL 上。128 张表的 DDL靠人眼核对执行结果是不现实的。我们后来写了个校验脚本发布前后各跑一次比对每个分片的表结构 checksum不一致直接卡住发布流程。这个脚本上线之后发布窗口从 3 小时回落到 45 分钟。订单号编码user_id这个做法值得多说两句。原来的订单号是纯雪花 ID拆分后按订单号查询无法定位分片。我们的方案是订单号 雪花 ID user_id 后 4 位做了简单混淆查询时先解出后 4 位再定位到具体分片。代价是订单号从 19 位变成 23 位前端和第三方对接方都要改收益是按订单号查询从扫 8 库 128 表变成精确路由 1 张表P99 从 1.4 秒降到 23 毫秒。复盘四年演进的真实账单把这四年的关键数字放在一起看比架构图有说服力阶段时间日订单量服务数机器数P99团队人数一次全量发布耗时单机第 1 年3 千11180ms48 分钟读写分离第 2 年3 万14210ms715 分钟服务化第 3 年12 万518340ms1520 分钟分库分表第 4 年45 万1240260ms2245 分钟有两个数字我一直拿来提醒团队。一是服务化之后 P99 反而从 210ms 涨到了 340ms因为一次下单从 1 次本地调用变成了 6 次跨网络调用每次 RPC 加上序列化和网络往返大概 15-25ms。这个代价是拆分的固有成本不是优化能完全消掉的。二是团队人数从 7 涨到 22 的同时人均产出的功能点其实是下降的——沟通成本、联调成本、跨服务排查成本都是新增的。我的取舍判断服务拆分的触发条件不该是数据量大而应该是团队协作卡住了。数据量大有分库分表、有读写分离、有缓存这些方案都不需要动组织结构。真正需要拆服务的信号是一次发布要协调三个小组、一个人改代码另外五个人要回归测试、代码库大到 IDE 索引都要五分钟。我们第三年拆服务回头看时机是对的因为那时候已经有四个业务小组在同一个仓库里抢发布窗口。分库分表能拖就拖。它带来的复杂度是全局性的查询要改、分页要改、统计要改、DDL 要改、监控要改。在上分库分表之前先看看这三条路走完了没有——冷热数据分离把两年前的订单归档到历史表、覆盖索引优化、把统计类查询挪到只读实例。我们真正到 1.8 亿行才拆现在回头看甚至还能再拖半年。架构演进不要跳步。我见过团队从单机直接上微服务 K8s Service Mesh结果三个月后连服务为什么 502 都定位不了。每一步演进都会暴露一批新的运维能力缺口跳步意味着这些缺口一次性全部暴露团队接不住。如果你现在日订单量在十万以内我更推荐单体 读写分离 好的模块边界这个组合。把包结构按业务域切干净Service 之间只通过接口调用数据库表按域分组不跨域 join——这套做扎实了未来真要拆服务的时候拆分成本会比从一团泥里往外扯低一个数量级。最后留个问题假设你的订单表已经 6000 万行业务方要求支持按手机号查询该用户全部订单而分片键是user_id。手机号和 user_id 是一对一的关系但查询入口只有手机号。你会怎么设计是维护一张phone - user_id的映射表放在单独的库还是做基因法把手机号信息编进 user_id或者干脆上一套 ES 做二级索引三种方案在一致性、运维成本、查询性能上各有什么代价欢迎在评论区说说你们的选择尤其是踩过坑的方案。
返回列表