ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

返利系统数据库优化实战:读写分离与分库分表完整复盘

返利系统数据库优化实战:读写分离与分库分表完整复盘 做返利系统这行最怕的不是业务逻辑复杂而是数据库在你毫无防备的时候突然塌掉。去年双十一凌晨我们平台的订单同步服务还在批量拉取联盟订单主库 CPU 直接冲到 97%所有返利状态查询全部卡在 InnoDB 的行锁上用户端一片待结算的红色告警运营群直接炸了。那次之后我花了整整两个月把数据库优化策略彻底重做了一遍读写分离 分库分表。今天这篇就是那次落地的完整复盘适合正在做返利、分销、CPS 这类读多写少但数据膨胀飞快的系统的后端工程师参考。1. 返利系统的数据库画像读多写少不代表压力小很多人在聊返利系统时都会下意识说一句这不就是读多写少嘛加几个从库不就完了。实际接手以后你会发现问题远没有这么简单。返利系统确实以读为主但它的写入模式非常特殊不是均匀的、用户触发的小写入而是定时拉单的批量写、月末结算的批量更新、提现打款的状态流转。这些写入一旦赶上用户查询高峰主库的锁竞争和复制延迟会同时爆发系统表现不是慢而是完全瘫痪。1.1 先看清楚返利业务的四条核心链路我习惯把返利系统的数据流拆成四条链路来理解因为每条链路的压力特征完全不一样用户浏览链路用户打开 App 查返利比例、搜商品、看精选榜单、查订单列表、查返利流水。这些请求几乎全是读QPS 最高但 SQL 简单绝大多数是带 userId 或商品 id 的主键/二级索引查询。订单同步链路平台定时去淘宝联盟、京东联盟等渠道拉取用户的下单、付款、确认收货状态。每轮同步可能一次性拉回几十万条状态变更落到库里是大量的 UPDATE 和 INSERT这是典型的周期性写洪峰。结算链路每天晚上跑批量任务把已过售后期、已经结算的订单标记为可提现给用户累计佣金余额写返利流水。这一轮操作会更新大量订单行还会更新用户账户余额。提现链路用户申请提现扣减余额、生成提现单然后等待打款回调。读少、写多但要求强一致绝对不能出现余额被扣了提现单却丢了这种事故。回到开头说的读多写少这里的写少指的是用户侧写入少但系统内部的批量写入一点都不少。问题就在这读请求天然适合水平扩展而批量写入会制造主从延迟、锁等待、慢查询这些才是返利系统数据库真正的杀手。1.2 数据库不是被并发压垮的是被三种情况拖垮的我复盘去年双十一事故时把慢查询日志和 InnoDB 状态翻了个底朝天最后总结出三根压垮主库的稻草这三根稻草在绝大多数返利系统里都存在第一是单行热点。平台里总有那么几个大团长、大淘客他们带动的订单量能占到全站百分之十几。所有运营报表、订单详情都集中在同一批 userId 的数据上单个用户的数据页被高并发访问行锁竞争和 buffer pool 的 latch 争抢非常严重。这种热点和普通高并发不一样加从库解决不了因为请求永远打在同一个数据页上。第二是批量更新拖出长事务。联盟订单同步任务为了保证一致性经常在一个事务里更新几万条订单状态。这个事务一旦和用户查询撞上undo log 膨胀、锁等待链变长、从库回放跟不上主从延迟从毫秒级直接拉到几十秒。用户查到的订单状态和真实状态严重不一致客服咨询量暴增。第三是单表数据膨胀后的慢查询。返利系统的订单明细表、返利流水表是第一年最容易膨胀的表。一百万订单的时候userId 索引非常听话到一千万的时候索引树的层级上来了历史数据一多范围查询和排序开始变慢过了三千万连简单的 count、分页都能把 CPU 打满。三条链路里的读流量可能再大也不会让 MySQL 立刻崩溃因为 InnoDB 的读扩展性其实挺强真正让系统崩掉的是上面这三类问题。读懂这张压力画像,再去做读写分离和分库分表才不会方向跑偏。1.3 什么时候才值得上读写分离和分库分表我见过不少团队在业务刚起步、单库跑得正欢时就忙着搞分库分表结果引入了分布式事务和跨分片查询一堆复杂度得不偿失。结合返利系统的实际数据特征我建议至少满足下面两三条再动手主库 CPU 长期在 60% 以上且慢查询日志里大量是 SELECT。读 QPS 与写 TPS 比例明显超过 10:1单纯靠加从库已经无法缓解主库锁竞争。订单明细表或返利流水表超过 1000 万行并且还在以每月百万级速度增长。大促期间需要支撑平时 5 到 10 倍的峰值流量而运维手里没有足够的扩容手段。批量同步订单和结算任务已经开始挤占核心业务查询的数据库资源连写后立即读这种基本需求都开始超时。如果只是偶尔一次大促扛不住我反倒建议先做缓存和 SQL 优化把热点商品、返利比例、订单列表都缓存起来说不定能多撑一年。但返利系统的数据特性注定了这条路走不长订单和流水是用户核心资产不能随便淘汰缓存数据规模过了千万就必须考虑读写分离再过了亿级分库分表就不可避免。关键是要在业务还扛得住的时候提前把方案想清楚。2. 读写分离落地MariaDB MaxScale 与应用层路由的实际选择读写分离是整个优化方案里见效最快的一步做法也相对成熟一个主库负责写一个或多个从库负责读读流量平均分发到从库上主库的压力立刻降下来。但在实际落地时有两个核心问题必须回答读流量怎么路由主从延迟怎么兜底2.1 两种路由方案的取舍返利系统里常见的读写分离路由方案有两种一种是引入代理层如 MariaDB MaxScale另一种是在应用层用数据源路由框架。这两种我都实际用过各有各的适用场景。代理层方案的代表是 MariaDB MaxScale。它部署在应用和数据库之间对业务代码完全透明应用连上 MaxScale 的端口就行它会自己解析 SQL 决定走主库还是从库。优点是 DBA 可以统一管控、加从库不需要改代码、还有自动故障切换能力缺点是所有数据库流量多一跳网络代理本身会成为新的单点而且它只能按 SQL 类型粗粒度分流对于一些需要写后读强一致的业务场景还是要靠规则来强制走主库。应用层方案则是把路由规则写在工程里。比如用 Spring 的 AbstractRoutingDataSource 配合自定义注解 Master、Slave在 Service 方法上声明走哪个数据源。好处是路由逻辑完全可控可以在代码里精细处理事务和延迟问题不需要额外维护代理组件坏处是侵入性强团队必须严格遵守规范一旦有人忘了标注或者新同学不懂约定就容易把读流量打到主库上。我用一张表把两边的关键差异列出来方便你结合自己的团队情况选对比维度MaxScale 代理层应用层数据源路由业务代码侵入无侵入连接串改一下即可需要加注解、切数据源逻辑路由粒度按 SQL 关键词粗粒度分流可以精细到方法级别主从切换自带监控和自动切换需要自研或依赖中间件运维成本需要单独运维代理机器无需额外组件部署简单强一致定制靠 hints 或规则不够灵活代码里好控制适用团队有专职 DBA库表较多后端团队自己管理数据库返利系统这种业务我最终是两套结合的核心交易链路走应用层路由因为要在代码里精细控制写后读的强制主库逻辑报表查询、运营后台这类低危流量走 MaxScale让运维统一管控从库和故障切换。2.2 MaxScale 读写分离代理的配置要点如果你用的数据库是 MariaDBMaxScale 基本是官方标配它和 MariaDB Server 的生态融合得非常好。这里我给出一个最简可用的 maxscale.cnf 配置骨架实际部署时把账号、IP、密码替换掉即可[maxscale] threadsauto [server1] typeserver address10.0.0.11 port3306 protocolMariaDBBackend [server2] typeserver address10.0.0.12 port3306 protocolMariaDBBackend [server3] typeserver address10.0.0.13 port3306 protocolMariaDBBackend [MariaDB-Monitor] typemonitor modulemariadbmon serversserver1,server2,server3 usermaxscale_monitor password强密码 monitor_interval2s auto_failovertrue auto_rejointrue [读写分离服务] typeservice routerreadwritesplit serversserver1,server2,server3 usermaxscale_route password强密码 master_accept_readsfalse max_slave_connections255 [读写分离监听] typelistener service读写分离服务 protocolMariaDBClient port4006这里有几个细节特别容易踩坑我逐个说明。首先MaxScale 的监控账号 maxscale_monitor 和路由账号 maxscale_route 权限不一样。监控账号需要能访问 mysql 库、执行 SHOW SLAVE STATUS、查看 performance_schema 里的复制信息否则监控不到主从延迟和故障路由账号则是后端业务连接用的普通账号权限不要给太大。这两个账号我见过很多团队混用最后排查问题时监控日志一直报权限错误主从切换根本触发不了。其次master_accept_reads 这个参数建议设成 false。它决定主库是否接收读流量写入请求已经是主库的单线程处理如果再把读流量压过去主库的 IO 和 CPU 压力下不来读写分离就失去了意义。从库不够了可以加从库不要让主库读。第三readwritesplit 会根据 SQL 类型自动分流INSERT、UPDATE、DELETE、DDL 和事务内的所有 SQL 都走主库SELECT 走从库。但它有个隐藏行为事务一旦开始事务内的所有语句都会被固定到主库上这是为了保证事务一致性合理但会减少从库的使用率。所以应用层尽量把只读查询放到事务外面别把简单查询包在一个大事务里。2.3 应用层注解路由的实现方式如果不想引入代理层应用层路由用 Spring 生态实现非常简单。核心思路是用 AbstractRoutingDataSource 在运行时动态决定当前线程用哪个数据源再用一个注解在方法上声明。下面是一个精简示例。先定义一个线程级的数据源上下文public class DynamicDataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void set(String key) { CONTEXT.set(key); } public static String get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }然后自定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default master; }切面在方法执行前把数据源名设置进 ThreadLocalAspect Component public class DataSourceAspect { Before(annotation(ds)) public void before(JoinPoint point, DS ds) { DynamicDataSourceContextHolder.set(ds.value()); } After(annotation(ds)) public void after(JoinPoint point, DS ds) { DynamicDataSourceContextHolder.clear(); } }最后在配置类里注册动态数据源Configuration public class DataSourceConfig { Bean public DataSource dynamicDataSource() { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource()); targetDataSources.put(slave, slaveDataSource()); // 可以配置多个从库按权重轮询或随机 DynamicRoutingDataSource routingDataSource new DynamicRoutingDataSource(); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(masterDataSource()); return routingDataSource; } }这样在业务方法上写 DS(slave) 就自动走从库不写就走默认主库规则非常简单。但我要特别提醒一个 Spring 事务的坑如果一个方法上有 Transactional事务会在进入方法时就绑定数据源连接这之后你再在内部切数据源是无效的连接已经和事务绑死在主库上了。所以我的经验是所有需要事务的方法一律强制走主库只读查询方法一律不要加 Transactional。2.4 主从延迟与写后读强制走主库读写分离上线后最大的敌人从主库 CPU 变成了主从延迟。MySQL 的主从复制默认是异步的从库回放主库的 binlog 需要时间正常情况延迟在毫秒级但遇到大事务、DDL、从库磁盘 IO 慢延迟就会被拉到秒级甚至分钟级。返利系统里最容易暴露延迟的就是写后读场景。用户刚提交提现申请你后端写完了主库页面紧接着要查最新余额如果这个查询走了从库读到的还是老余额用户就会觉得提现没成功反复点提交产生一堆重复单。我处理这类问题的办法有三层第一层在代码层面强制写后读走主库。凡是同一个用户在同一会话内刚发生写过操作又立即读的场景读请求直接标记为主库执行。最粗暴但有效的做法是因为现在读多写少比例悬殊这类核心读走主库的成本完全可接受关键业务不会错。第二层使用短时间本地缓存路由表。比如用户提交提现后 3 秒内这个 userId 的查询一律路由到主库。实现就是在 Redis 里设置一个带过期时间的 key查询时看到这个 key 就切主库。这个方案可以覆盖绝大多数用户刚操作完立刻刷新的场景。第三层用复制心跳监控从库延迟。Percona Toolkit 的 pt-heartbeat 工具会在主库周期性写入心跳时间从库通过对比当前时间来算出精确延迟。我把告警阈值设在 3 秒任何一个从库延迟超过阈值就把它的读流量摘掉等追平后再恢复。这样不仅避免用户读到脏数据也保护了从库不被持续拖垮。3. 分库分表的具体拆分订单、流水、提现记录读写分离解决的是并发读压力但数据库数据量一旦到了千万、亿级单表本身的性能瓶颈就出来了。返利系统的订单明细、返利流水膨胀速度极快是我做分库分表的首批目标。3.1 分片键锁定 userId 的理由分库分表第一件事就是选分片键这个选择直接决定未来所有查询的形态。返利系统里我几乎没有犹豫就选了 userId原因是这个业务的访问模式太清晰了用户查返利比例是按 userId 关联的查订单列表是按 userId 的查返利流水也是按 userId 的甚至订单同步回来确认归属时也是按 userId 去更新用户的返利记录。以用户维度分片天然把所有热点数据放在同一个分片上用户订单、流水、余额可以做成局部性很强的一组数据。对比一下用 orderId 分片的后果用户查我的订单列表时你不知道他的订单落在哪个分片上只能向所有分片发起查询然后聚合排序这就是典型的跨分片查询灾难。更麻烦的是结算任务按订单更新状态时如果订单和用户余额不在同一个分片就需要分布式事务复杂度直接翻倍。所以选择 userId 作为分片键本质上是把用户的数据内聚在同一个分片内让结算、提现这类资金相关操作可以在单分片内用本地事务完成。返利系统的业务特性决定了这个选择几乎是一本万利。3.2 分片算法、全局主键与扩容分片算法我建议先做简单的取模再用一致性哈希过渡到分段映射不要一上来就搞很复杂的算法。假设我们规划 16 个物理分片用户 id 是 10086那它落的分片就是 10086 % 16 6。这个算法足够简单路由时计算开销几乎为零配合分片配置表就能解决绝大多数问题。但取模有一个硬伤扩容时几乎全部数据都要迁移。16 个分片扩到 32 个原来分片 0 里的数据按新规则计算可能要去分片 0、16、20、31 等等数据基本全动。所以我在设计时提前做了一步把 userId 先通过一致性哈希映射到一个逻辑分片再把逻辑分片映射到物理分片。这样扩物理库时只迁移一部分逻辑分片的数据。说说我们当时的扩容操作流程这套流程后来也成了团队的标准动作在配置中心发布新的分片映射规则路由层先开启新老双读读流量同时查询新旧分片以新分片为准老分片数据只做校验。启动离线迁移任务按逻辑分片为单位把老分片的数据按新规则写入对应新分片过程中记录迁移进度和校验位点。每个逻辑分片迁移完成后对比新老库的行数、金额 sum、MD5 校验值全部一致才算通过。全量迁移完成后把写流量切到新规则保留老分片只读状态观察一段时间。观察 3 到 7 天无异常下线老分片。还有全局主键也必须提前设计。多分片下不能用数据库自增 id 当主键否则多个分片会生成重复 id订单号、流水号又会拿这个 id 去关联别的地方撞车就乱套。我们用的是雪花算法生成的 64 位 Long 型 id特点是趋势递增、全局唯一非常适合返利系统的订单表、流水表、提现表。生成时注意把机器 id 和数据中心 id 配置好避免部署多实例后重复。3.3 订单与返利流水的表结构规划分库分表不是只能分库实际落地时我把分库 分表 冷热归档三层叠加在一起。以订单明细表为例表名规划是 cashback_order_{0..15}十六张表按 userId 取模分布在这十六张表内部再按订单创建时间的月份做分区。元数据上再用一张配置表记录当前活跃分片、历史分片状态。返利流水表的设计也类似rebate_flow_{0..15}按 userId 分片同时按流水产生月份分表。这样做的原因是流水表是所有表里增长最无情的用户每笔订单的状态变化都要写流水一条订单从下单到结算可能产生 3 到 5 条流水数据量是订单表的三倍。提现记录表反而简单按 userId 分片即可提现频率远低于订单不必再做月份分表。但提现表有个特殊要求必须给 (userId, withdraw_no) 建唯一索引。返利系统的提现模块经常收到重复回调或前端重复提交唯一索引是防重复最底层的屏障。这里给出我们线上表规划的核心参考表名分片规则保留策略关键索引说明cashback_order_{0..15}userId % 16热表保留 90 天超过归档uk(order_id)、idx(user_id, create_time)订单状态变化频繁必须按用户和时间双索引rebate_flow_{0..15}userId % 16保留 2 年idx(user_id, create_time)、idx(order_id)流水量大按用户与时间查是常态withdraw_record_{0..15}userId % 16永久uk(user_id, withdraw_no)、idx(user_id, status)资金表严格幂等防止重复扣款user_account_{0..15}userId % 16永久pk(user_id)用户佣金余额资金类严禁全表扫描3.4 绕开跨分片查询的三条路径分片键选了 userId日常用户维度的查询都舒服了但总有一些查询天然不带 userId比如运营后台要查全局订单趋势、财务要汇总当天全站返利金额。这种跨分片查询如果直接在业务库上广播执行十六张表、上亿行数据一个聚合 SQL 就能拖垮全部分片。我的处理方式是尽量把跨分片查询从 OLTP 链路里剥离出去。运营报表、财务汇总全部走独立的数据通道每天定时从各分片的从库同步一份汇总数据到分析库或者灌入 ElasticSearch / ClickHouse报表查询只打这套分析系统。第二条路径是分片并行任务。比如订单同步任务需要扫描全局订单那就按分片拆成 16 个 task每个 task 只处理自己分片的数据并行跑。这个方案对批量任务特别有效因为每个分片的数据互相独立完全可以并行处理整体吞吐是单线程的 16 倍。第三条路径是禁止无分片键的深分页和 join。用户订单列表的分页一定要带 userId 条件让 SQL 落在单个分片内执行跨分片查询如果用 limit 100000, 20 这种写法每个分片都要扫描十万行再合并排序性能必然爆炸。我统一改成游标分页用上一页最后一条记录的 create_time id作为下一页的查询起点实测 TP99 能降一个数量级。4. 一致性优先从主从延迟到资金事务的边界设计读分库分表改造最容易出事的不是性能而是数据一致性。返利系统里有真金白银的余额和提现一致性要求比一般业务高很多。我在这个项目里最大的体会是不要把问题升级到分布式事务层面去解决而是通过合理的数据分布和业务设计让大部分分布式问题变成单库本地问题。4.1 读写分离下的一致性读策略读写分离上线后查询读到旧数据的问题几乎天天有人反馈。除了前面说的延迟监控还有一个细节容易被忽略批量任务自己产生的数据如果批量任务内部有写完立即查的逻辑也常常打到从库导致查到旧值。比如订单同步任务刚把一批订单状态改成已确认紧接着去查这批订单算返利结果查到几天前的状态返利金额少算或漏算。我的统一策略是任何写操作所在的方法内后续的读操作必须走主库只有独立于写路径之外、对时间不敏感的查询才允许走从库。用个直白的话说就是写完就读的别贪从库那点性能从库只服务那些晚几秒看到也无所谓的页面。在代码落地时我给所有 Service 方法分了两类一类是命令方法有写操作方法内全部用默认主库数据源不切从库另一类是查询方法才允许使用 DS(slave)。靠这个简单约定团队里的写后读脏读问题基本绝迹了。4.2 结算与提现如何用本地事务解决分布式问题前面选 userId 分片的好处在资金相关事务上体现得最彻底。看一个具体的结算场景订单确认收货后系统要做三件事更新订单的返利状态为可提现、给用户账户余额累加返利金额、写入一条返利流水。因为订单表和账户余额表、流水表都按 userId 分片这三张表在同一个物理分片里那么一个本地事务就能搞定BEGIN; UPDATE cashback_order_6 SET rebate_status settled WHERE order_id ? AND user_id 10086; UPDATE user_account_6 SET available_amount available_amount ? WHERE user_id 10086; INSERT INTO rebate_flow_6 (flow_id, user_id, order_id, amount, status) VALUES (?, 10086, ?, ?, settled); COMMIT;这个事务只在分片 6 的数据库上执行没有跨库不需要两阶段提交。只要三行数据都在同一分片MySQL 本地事务就保证了原子性要么全部成功要么全部回滚。这个设计的价值在实际运维中会体现得非常充分我见过团队把订单库和账户库拆成独立的微服务库然后去搞柔性事务、消息补偿光排查返利加了但流水没写的故障就花了几周。数据分布设计得当这些复杂度根本不应该存在。提现业务的逻辑类似扣减用户余额、创建提现单、更新提现单状态全部在 userId 分片内本地事务完成。提现单状态机我建议定义成待处理、打款中、成功、失败、已退回。每一笔提现从创建到终态状态流转要记录操作人和时间方便对账。4.3 对账、幂等与失败补偿即使本地事务保证了单分片内的原子性整个系统的最终一致性还需要对账来守护。返利系统每天凌晨必须跑三类对账一是分片内对账每个分片独立执行统计本分片的订单数、返利总金额、用户余额总和、流水笔数然后汇总到全局。任何一个分片数据异常都能快速定位到具体分片而不是全表撒网排查。二是联盟侧对账把系统内的订单金额、返利金额和淘宝联盟、京东联盟后台的汇总数据做对比。联盟接口偶尔会丢回调、延迟回调这种对账能把漏掉的订单捞回来是返利系统资金安全的重要防线。三是幂等兜底订单同步任务天然会重复拉取同一笔订单因为联盟接口拉取窗口可能重叠。我的做法是在订单表加唯一索引 uk(platform_order_id)INSERT 用 ON DUPLICATE KEY UPDATE 做幂等更新。提现打款回调也可能重复通过 withdraw_no 唯一索引兜底。至于失败补偿我把所有异步任务都接入了 MQ 重试 本地消息表。比如提现打款请求发出后如果支付回调一直不来定时任务会重新扫描状态为打款中且超过 30 分钟的提现单主动查询支付平台状态。这套机制跑了一年多最坏情况下也能保证在 15 分钟内追平异常单。5. 容量评估与压测验证这套方案到底扛住了多少流量做完读写分离和分库分表之后我心里其实一直没底因为架构升级谁都会说真正验证它能不能抗住大促流量得靠数据说话。这里我讲一下我们的容量评估方法和压测结果给你一个可参考的量化过程。5.1 按业务量反推开分片数和从库数以我们平台为例子注册用户 500 万日活 50 万日常页面 PV 2000 万其中 80% 是返利比例查询、订单查询这类读请求。日均订单同步量在 100 万笔大促峰值能到平时 8 到 10 倍。先算读容量。MySQL 单实例在硬件正常、SQL 有索引的情况下混合读写 QPS 大概能到 4000 到 6000。日常平均读 QPS 约 2000 万 PV / 86400 秒约等于 230但这是平均值高峰期至少放大 10 倍也就是 2300大促再放 10 倍就是 23000 的峰值读 QPS。一颗主库当然扛不住我规划了 1 主 3 从主库负责写和核心读3 个从库分担日常读流量。大促前临时扩容到 5 个从库单个从库峰值压在 5000 QPS 左右比较安全。再算数据容量。日均 100 万笔订单一年就是 3.65 亿如果不分表订单表直接爆掉。按 16 个分片算每个分片一年约 2280 万行看起来还行但如果连续跑 3 年单分片接近 7000 万行仍然偏大。所以我在 16 分片的基础上再加了 90 天冷热归档超过 90 天的订单导到归档库业务库里单分片只保留近三个月约 900 万行压力大大减轻。账面数字算完方向就有了读写分离解决并发读分库分表解决数据膨胀冷热归档解决历史包袱三者配合而不是各自为战。5.2 压测怎么设计才贴近真实业务系统上线前必须压测但压测设计不合理会给你虚假的安全感。我压测时没有只压一个简单的 SELECT 1而是按线上实际请求比例构造了混合场景返利比例查询 40%、订单列表 30%、流水查询 20%、订单同步写入 5%、结算更新 5%再叠加部分无索引或者范围查询模拟慢请求。压测工具上基础性能用 SysBench 测 MySQL 单机极限业务场景用 JMeter 模拟 HTTP 接口。重点关注四个指标整体 QPS/TPS、TP99 延迟、主从延迟水位、连接池占用率。我们压出来的结果是这样的单主库混合读写 QPS 大约是 4500TP99 在 50 毫秒加了 MaxScale 和 3 个从库之后整体读 QPS 到了 13000 左右TP99 稳定在 80 毫秒以内主库的 TPS 保持在 800 到 1000CPU 占用从之前的 90% 降到 30%。瓶颈反而转移到了 MaxScale 的连接数和后端连接池配置上——连接数一旦超过阈值代理层开始排队吞吐不升反降。这个发现告诉我们架构改造后还要同步调连接池参数不能只盯数据库本身。5.3 大促前一周的备战清单大促前我会带着运维团队把这些事情全过一遍差一项都不敢拍胸脯从库提前扩容到位延迟监控阈值配置好告警能直达值班群。慢查询日志全量打开提前一天预跑一遍大促核心 SQL收集执行计划。批量任务错峰联盟订单同步从每小时一次改成每 10 分钟小批量拉取结算任务挪到凌晨 2 点到 5 点低峰期执行避免和流量高峰叠加。缓存预热把热门商品返利比例、热门榜单提前加载到 Redis减少后端穿透到数据库的读请求。限流降级预案一旦主库水位告警先对查询量最大的几个接口做限流返利比例查询降级为读取缓存中的近似值。主从切换演练大促前强制做一次从库提升演练确保 MaxScale 的自动切换不是纸面功能。这套备战清单后来成了标准操作流程今年大促我们最高单日订单量到了 1100 万笔数据库层面没有再出现过一次严重告警。6. 落地过程中踩过的坑和对应的处理方式写了这么多方案最后分享几个我们真实踩过、并且修复代价不小的坑。这些坑在文档里不容易看到但对正在规划同样改造的你很有参考价值。6.1 复制链路与代理账号的坑上线 MaxScale 后的第一个月就遇到过一次从库延迟报警但查不出原因的情况。后来发现是主库 binlog_format 设置成了 STATEMENT从库回放大事务时同一批 SQL 在不同从库上执行的时间差异很大导致延迟抖动。统一改成 ROW 格式之后问题解决。注意ROW 格式下 binlog 体积会变大很多需要盯着磁盘容量别让 binlog 把磁盘塞满这是另一个常见的坑。监控账号的坑前面提过MaxScale 的 mariadbmon 模块需要有 SHOW SLAVE STATUS 和读 mysql 系统库的权限。我见过有人用业务账号当监控账号结果主库宕机时 MaxScale 根本没感知到failover 全程没触发业务挂了二十分钟。给监控账号单独授权、单独密码并定期用 SHOW REPLICA STATUS 验证监控账号能看到复制状态。6.2 事务方法里切数据源路由失效的坑这个坑是应用层路由方案最经典的问题。有段时间我们的订单列表接口偶尔会报事务已开始不能切换数据源的错误排查后确认是有个查询方法被加了 Transactional(readOnly true)方法内部又调用了标记 DS(slave) 的 Mapper 方法。因为 Transactional 一进入就绑定主库连接再切数据源完全无效所有查询全部压到了主库上。处理办法是双管齐下一方面明确约定事务方法内部不允许切数据源代码 review 时专门检查另一方面配置里把只读事务的 default 数据源设置为从库这样即使有人写 Transactional(readOnly true) 也不会误伤主库。如果你用的是 ShardingSphere它的事务和读写分离规则也有类似问题记住一个原则事务边界优先于数据源路由。6.3 分片后的分页与跨分片统计上线分库分表后运营要拉一份全站订单明细直接 SELECT * FROM cashback_order LIMIT 1000000, 20这个 SQL 在十六个分片上各自执行了一遍每个分片都扫描了几百万行把数据库 CPU 打到 80%。我拉上运营聊了需求本质他们要的只是一份导出文件不是在线查询。于是改成定时生成导出任务按分片并行扫描每个分片只导当天增量最后合并文件报表需求改走数据通道在线查询全部限制只能带 userId。跨分片统计也踩过类似的坑。财务要实时的全站返利总额最初前端直接调聚合接口十六个分片实时 sum响应时间 5 秒以上。最后改成每 5 分钟在分析库里预聚合一次总额接口只需查一行汇总记录响应降到几十毫秒。6.4 灰度迁移老数据的顺序分库分表改造最怕一次性全量切换出问题连回滚的机会都没有。我们当时的顺序是先在测试环境用影子库验证路由规则再在预发环境跑全量迁移演练最后在生产环境按逻辑分片灰度。灰度粒度控制在每晚只迁移 1 到 2 个逻辑分片每迁完一个分片就跑一遍对账脚本确认新老库数据完全一致第二天早上观察业务无明显异常再继续下一批。整个过程花了接近两周同事觉得太慢了但事实证明慢就是快期间确实发现过两次迁移脚本对金额精度处理不一致的问题都被对账拦截在了小范围内没有影响线上用户。如果是赶在大促前十天一把梭迁移大概率会出大事。回看这次数据库优化我自己最深的体会是读写分离和分库分表不是目的而是为了让返利业务的核心链路——查返利、同步订单、结算、提现——在数据增长和流量洪峰下依然可维护、可预期。所有技术选型都围绕一个原则做尽量把复杂问题收敛到单库局部去解决实在收敛不了的用异步、对账和幂等来兜底。最后留一个小技巧给你上线前一定要折腾一次真实的故障演练把主库宕机、从库延迟、分片迁移失败各演一遍演练时出的洋相都是大促当天可能救你命的经验。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表