☰
返利系统数据库优化实战:读写分离与分库分表完整复盘
2026/9/29 20:40:50 网站建设 项目流程

做返利系统这行,最怕的不是业务逻辑复杂,而是数据库在你毫无防备的时候突然塌掉。去年双十一凌晨,我们平台的订单同步服务还在批量拉取联盟订单,主库 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 读写分离代理的配置要点

如果你用的数据库是 MariaDB,MaxScale 基本是官方标配,它和 MariaDB Server 的生态融合得非常好。这里我给出一个最简可用的 maxscale.cnf 配置骨架,实际部署时把账号、IP、密码替换掉即可:

[maxscale] threads=auto [server1] type=server address=10.0.0.11 port=3306 protocol=MariaDBBackend [server2] type=server address=10.0.0.12 port=3306 protocol=MariaDBBackend [server3] type=server address=10.0.0.13 port=3306 protocol=MariaDBBackend [MariaDB-Monitor] type=monitor module=mariadbmon servers=server1,server2,server3 user=maxscale_monitor password=强密码 monitor_interval=2s auto_failover=true auto_rejoin=true [读写分离服务] type=service router=readwritesplit servers=server1,server2,server3 user=maxscale_route password=强密码 master_accept_reads=false max_slave_connections=255 [读写分离监听] type=listener service=读写分离服务 protocol=MariaDBClient port=4006

这里有几个细节特别容易踩坑,我逐个说明。

首先,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 ThreadLocal<String> 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"; }

切面在方法执行前把数据源名设置进 ThreadLocal:

@Aspect @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() { Map<Object, 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 先通过一致性哈希映射到一个逻辑分片,再把逻辑分片映射到物理分片。这样扩物理库时,只迁移一部分逻辑分片的数据。

说说我们当时的扩容操作流程,这套流程后来也成了团队的标准动作:

  1. 在配置中心发布新的分片映射规则,路由层先开启"新老双读",读流量同时查询新旧分片,以新分片为准,老分片数据只做校验。
  2. 启动离线迁移任务,按逻辑分片为单位,把老分片的数据按新规则写入对应新分片,过程中记录迁移进度和校验位点。
  3. 每个逻辑分片迁移完成后,对比新老库的行数、金额 sum、MD5 校验值,全部一致才算通过。
  4. 全量迁移完成后,把写流量切到新规则,保留老分片只读状态观察一段时间。
  5. 观察 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 大约是 4500,TP99 在 50 毫秒;加了 MaxScale 和 3 个从库之后,整体读 QPS 到了 13000 左右,TP99 稳定在 80 毫秒以内;主库的 TPS 保持在 800 到 1000,CPU 占用从之前的 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 个逻辑分片,每迁完一个分片就跑一遍对账脚本,确认新老库数据完全一致,第二天早上观察业务无明显异常,再继续下一批。

整个过程花了接近两周,同事觉得太慢了,但事实证明慢就是快:期间确实发现过两次迁移脚本对金额精度处理不一致的问题,都被对账拦截在了小范围内,没有影响线上用户。如果是赶在大促前十天一把梭迁移,大概率会出大事。

回看这次数据库优化,我自己最深的体会是:读写分离和分库分表不是目的,而是为了让返利业务的核心链路——查返利、同步订单、结算、提现——在数据增长和流量洪峰下依然可维护、可预期。所有技术选型都围绕一个原则做:尽量把复杂问题收敛到单库局部去解决,实在收敛不了的,用异步、对账和幂等来兜底。最后留一个小技巧给你:上线前一定要折腾一次真实的故障演练,把主库宕机、从库延迟、分片迁移失败各演一遍,演练时出的洋相,都是大促当天可能救你命的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询