先说结论在我接手这个券码系统的改造之初系统里确实是用 order_id 做分片键的。日均 500 万张券码发放听起来量不大但一旦遇到线下大促、活动开抢、用户批量核销order_id 分片的缺陷会成倍放大。我们最后把分片键改成了券实例 ID也就是券的内部主键 id核心高频读写全部收敛到单分片整体平稳度过了后续所有大促。这篇文章就把这次分库分表键选型的完整思考过程、踩坑记录和落地细节写出来给正在做类似营销发券系统的同学一个参考。1. 日均500万张券码这个场景到底难在哪1.1 券码系统的数据画像首写多、后续写少、最终读多做分库分表前得先想清楚一类业务的数据访问特征。券码系统和普通订单系统最大的不同是它的生命周期特别长而且读写模型是阶段性变化的。我习惯把券码系统的数据访问拆成三个阶段第一写入阶段用户下单、活动触发、批量采购系统一次性生成一批券实例的初始记录。这个阶段全是 insert而且经常是批量 insert。中途低频写阶段用户领取某张券把它从“未发放”置为“已发放”绑定 user_id 和领取时间。这个阶段是 update但单次只更新一张券。最终高频读写阶段消费者到店核销、查询券包、查看有效期系统要把券从“已发放”置为“已核销”或“已过期”。这个阶段读和写同时存在且核销动作直接关系到资金和服务成本。很多团队在设计分库分表时脑子里只有“订单流程”的印象把券当成订单的附属品。实际上券一旦发出去它的生命周期就独立于订单了。订单可能几天内就完结券却可能要在用户手里存活小半年。所以券码系统的本质是一条记录被写入之后会在未来很长一段时间内被高频地、独立地查询和更新。这决定了分片键的选择不能偷懒。1.2 单库单表不是不能存而是扛不住峰值写和热行锁我先把量级算一笔账这样大家能直观理解为什么要分库分表。日均 500 万张券单条券实例记录按 600 字节估算id、order_id、user_id、券码、状态、金额、有效期、扩展字段等一天新增的数据量约 300 MB。一个月累计约 1.5 亿行数据量大概 9 GB。这个量级对 MySQL 的存储来说并不吓人。问题不出在存量出在流量模型。发券通常集中在某个活动时段比如渠道投放、开屏弹窗、订单支付完成可能 1 小时内发掉当天 80% 的量。粗略算一下500 万张券集中在 1 小时内发出平均每秒接近 1400 次批次写如果再叠加上用户端同时在线核销、查询单库的 TPS 很容易冲到几千甚至更高。而单机 MySQL 在开启 binlog、双一刷盘配置下的稳定写 TPS 也就是几千的量级一旦出现热行更新比如同一张券被反复核销、同一个订单下几百张券被并发操作行锁竞争会直接把性能拖垮。单库单表最致命的是全局只有一个写入入口而券码场景里的写不只是 insert还有大量状态 update。update 需要先定位行、加行锁、更新、提交任何一个偶发的大事务都会放大整个库的抖动。所以我们做分库分表目标并不是“把数据分散到多个地方存”而是把并发争用分散到多个独立的锁粒度上把一次高频操作限制在单分片内完成。1.3 分片键才是分库分表最先要定的事很多团队一上来就研究分库分表中间件、MyCat、ShardingSphere、Proxy 方案但我觉得这是本末倒置。分库分表架构里最核心、最难改、最影响未来两三年系统走向的就是分片键。分片键一旦定错后面所有 SQL、事务方案、扩容机制都跟着错。而且线上数据量一旦上来再改分片键就是伤筋动骨要双写、要迁移、要校验、要灰度稍有不慎就是数据事故。所以这篇分享的核心就聚焦一个问题在券码发放场景下分片键到底应该怎么选为什么最终放弃了 order_id转而使用券实例 ID。2. 分片键选型的第一性原则先列高频SQL再谈分片键2.1 把券码系统里的核心SQL全部拉出来我做选型时有个习惯不谈业务概念先把系统里最高频的 SQL 全部列出来看它们的 where 条件到底长什么样。因为分片键的价值最终要落到 SQL 上一条 SQL 能不能在单分片执行取决于 where 条件里有没有携带分片键。券码系统里的核心 SQL 大致如下按券实例 ID 查单张券select * from coupon_instance where id ?按券实例 ID 核销update coupon_instance set status 2 where id ?按用户 ID 查我的券包select * from coupon_instance where user_id ? and status 1 order by expire_time按订单 ID 查这个订单下发了哪些券select * from coupon_instance where order_id ?按外部券码用户输入的兑换码核销select * from coupon_instance where coupon_code ?后台统计某个活动发了多少券、核销了多少select count(*) from coupon_instance where batch_id ? and status ?把这张清单摆出来之后答案其实已经浮出水面了。2.2 谁出现在高频SQL的where里谁就是分片键我们一项项看核销、查券、状态流转这些都是最高频的接口它们的 where 条件几乎都包含券实例 ID。用户查券包虽然是按 user_id 的但券实例 ID 和 user_id 是多对一关系这个查询本身是低频接口里的高频用户打开券包的频率远低于核销频率而且它可以通过冗余表解决。订单维度查券只是客服查证、订单详情展示时用频率低一个量级。批量统计查询压根就不该放在在线库里做应该走离线数仓或异步导出。所以结论很清楚最高频、最关键、最不容有失的那批 SQLwhere 条件里都是券实例 ID。分片键就必须优先服务于这批 SQL。这里我想强调一个选型原则很多文章不提分片键的选择依据不是“数据从哪里来”而是“数据最频繁地被谁、以什么条件访问”。订单产生的券不一定非要按订单来分片。两种选择在业务上都能解释但工程上必须选那个让高频 SQL 变成单分片 SQL 的键。2.3 为什么不建议用外部券码本身做分片键这里还要澄清一个容易混淆的点用户手里的券码比如一串 16 位字母数字和系统内部的券实例 ID是两回事。有些同行会问券码也是唯一的而且核销时用户只提供券码为什么不用券码做分片键我的答案是外部券码不适合当内部路由键原因有三个。第一外部券码通常需要做到不可枚举、不可预测防止黑产批量生成有效码去扫。如果拿它做分片键意味着它必须是稳定、可拆解、可计算的这是和安全需求相悖的。第二外部券码可能被重新生成、作废、换绑。一旦外部标识发生变化分片路由就失效了。内部主键则是永不修改的。第三券码通常是字符串字符串哈希取模做分片在性能上完全没问题但运维排查问题时没人能一眼看出某个券码属于哪个分片。内部 ID 配合路由规则可以做到快速定位可观测性更好。所以正确做法是内部用全局唯一的券实例 ID 做主键和分片键外部展示用独立的 coupon_code两者之间建立唯一索引映射。3. 放弃 order_id 的核心理由三个致命缺陷3.1 致命缺陷一一个大订单就能打爆单个分片很多人选 order_id 分片是因为脑袋里有一个根深蒂固的念头券是订单产生的订单是一张券的“父亲”按父亲分组再自然不过。但这个想法忽略了订单大小的极端差异。普通用户在电商平台买一单可能只带出一两张券这是常态。但企业采购、渠道批量发放、大促集中引流时一个订单里放几千甚至几十万张券是真实存在的。我当时接手系统的时候就见过一个渠道采购单一次买了 100 万张券全部挂在同一个 order_id 下。如果分片键是 order_id这 100 万张券就会百分之百落到同一个分片里。后果是单个分片的数据量瞬间膨胀是其他分片的几十倍甚至上百倍。批量插入这 100 万张券时会在一个分片里产生超长事务binlog 堆积、主从延迟飙升。后续这 100 万张券被陆续领取、核销时所有 update 都击中同一个分片这个分片就成了整个系统的“单点热分片”。我印象特别深的是那段时间监控面板上其他分片都很安静就某一个分片的连接数、QPS、磁盘 IO 全部拉满。DBA 半夜打电话给开发的场景到现在我都忘不了。这就是 order_id 分片最典型的死法它不是慢慢变慢而是被一个异常大订单直接在数据分布和访问热点上“打爆”。3.2 致命缺陷二核销和券包查询全部变成跨分片分库分表之后跨分片查询是大忌因为它会把“分片并行”变成“聚合等待”。用 order_id 分片核销链路会变成什么样用户拿着券到店消费收银台提交核销请求请求里只有券码或券实例 ID没有 order_id。系统拿到这个 ID 后无法直接判断它在哪个分片只能把查询广播到所有分片等所有分片返回结果后再在内存里聚合。这个过程中只要有一个分片慢整个核销接口就跟着慢。用户查券包也一样。用户 ID 对应的几张券可能来自好几个不同的订单也就散落在好几个分片里。查一次券包等于把所有分片都扫一遍再按状态、时间排序合并回一个列表。平时数据量小还能忍到了大促期间这个查询就是雪崩的引线。我算过一个很粗略的账假设有 32 个分片一次跨分片聚合查询的耗时至少等于一次单分片查询的 RT 加上分片间网络开销和归并排序时间。如果串行查询耗时会变成几倍即使并行查询也受最慢分片的拖累。原本 5 毫秒能返回的单行查询跨分片后会变成 30 到 100 毫秒高峰期连接池一旦被慢查询占满整个服务的可用性就直接崩了。分库分表的核心价值就是让高频 SQL 在单分片上完成。如果选了 order_id等于亲手把最高频的核销查询和券包查询变成了跨分片 SQL分库分表不仅没有带来收益反而制造了灾难。3.3 致命缺陷三券的生命周期与订单生命周期严重错配订单的归宿是完结券的归宿是核销或过期两者的生命周期完全不是一个量级。订单表通常在 90 天或 180 天后就会被归档、清理或者迁移到冷存储因为订单已经完成售后再留在热库里只会占用空间。但券不一样一张有效期为一年的券在下单后 365 天内都会被用户查询、核销。也就是说券数据的活跃期远长于订单数据的活跃期。如果按 order_id 分片券表的分片路由强依赖订单表的存在。订单一旦归档或者清理券的路由逻辑、后台查询、对账脚本全都会出问题。而且开发同学在清理订单数据时还得小心翼翼确认名下所有券都过了有效期才敢动手这种耦合让平常的运维操作动不动就引发线上事故。反过来按券实例 ID 分片后券表是彻底独立的存在。订单表建不建、归档不归档都不影响券的查询和核销。券的清理、过期、冷数据归档完全可以按自己的 expire_time 来做。3.4 两个方案的综合对比我在设计评审时习惯用一张对比表把问题摊开这里也分享出来对比维度order_id 分片券实例 ID 分片数据分布一个订单的所有券集中在一个分片大订单会产生严重倾斜每张券独立散列分布天然均匀不受订单大小影响核销查询请求没有 order_id需要全分片广播聚合按券实例 ID 精确路由到单分片单行用户券包查询用户名下券来自多个订单必然跨分片通过 user_id 冗余表实现单分片查询与订单生命周期耦合强耦合订单归档后路由失效完全解耦券独立管理扩展性大订单热点导致分片容量不均扩容难以持续分片间负载均衡逻辑分片固定后物理迁移平滑安全order_id 递增容易被猜测枚举内部 ID 离散生成无法反推数据量这张表列完之后选哪个已经没有任何悬念了。4. 券实例ID分片的优势为什么它才是正确的键4.1 天然均匀分布彻底消除大订单热点券实例 ID 是每张券的唯一内部主键它的最大特点是每一张券都是独立的最小业务单元。按订单号分片时一个订单是数据的分组单位按券实例 ID 分片时一张券才是数据的分组单位。前者的颗粒度是“批”后者的颗粒度是“个”。当颗粒度细化到单张券时不管一个大订单里塞了 1 张还是 100 万张券在路由层看来都是 100 万个独立 ID通过取模或者哈希后会被均匀地散列到所有分片上。这意味着数据量和访问热度都天然均衡没有任何单个分片会因为某个大订单而被打爆。这一点对券码系统的稳定性是关键中的关键。4.2 读写路径统一收敛到单分片单行券码系统的高频操作本质上都是围绕单张券的状态机流转发券按券实例 ID update 状态。领券按券实例 ID update 归属。核销按券实例 ID 查状态、update 状态。查看单券按券实例 ID 查询。这些操作命中同一分片甚至命中同一行。在单分片内事务是完整的行锁只在分片内生效不需要引入分布式事务也不需要做跨分片归并。打个比方按 order_id 分片等于把所有同一订单的券捆在一个袋子里丢进同一个货架按券实例 ID 分片等于把每张券单独放到一个格子里。格子之间互不干扰任何一个格子的读写都不会阻塞其他格子。4.3 与业务形态解耦后续扩展不用改键业务的演进在营销领域非常快。今天发的是满减券明天可能发兑换券、权益卡、邀请码、礼品卡后天可能把券和会员体系、积分体系打通。如果分片键绑定在订单维度上这些新业务一旦不是从订单出发分片逻辑就得推翻重来。而券实例 ID 是一个纯粹的、与业务来源无关的标识符不管这张券是从订单产生的、从活动页领取的、还是从外部渠道导入的它都有一套统一的 ID 生成和路由机制。我用券实例 ID 分片之后后面接了好几个新的发券渠道路由层完全没有任何改动只是新增了上游来源字段。这节省了非常多的开发成本和回归测试成本。4.4 安全性和数据清理也顺带解决了券实例 ID 使用内部发号器生成带时间戳和随机因子外部无法通过 ID 推测出发券总量、分片数量等敏感信息。这一点比直接用自增 order_id 分片要从容得多。同时券的冷热归档可以按券自己的生命周期来做。比如按 expire_time 分区过期超过半年的券自动归档到冷库完全不影响在线路由。这个自由度是 order_id 分片给不了的。5. 券实例ID分片的落地实操从发号器到核销链路5.1 券实例ID怎么生成全局唯一 足够离散分片键是券实例 ID那这个 ID 的生成质量就直接影响分片效果。我这里的经验是必须全局唯一分库后不能再依赖任何单库的自增 ID。必须足够离散ID 的末几位在取模时要能相对均匀地分布在各分片。不能有业务含义不能从 ID 反推出用户信息或订单信息。不能太长建议用 bigint方便索引和路由计算。我们用的是改造版雪花算法public class CouponIdGenerator { // 64位1位符号位 41位毫秒时间戳 10位机器/进程位 12位序列号 private final Snowflake snowflake new Snowflake(workerId, dataCenterId); public long nextCouponInstanceId() { return snowflake.nextId(); } }发号器用独立服务或者本地内嵌都可以但一定要保证高可用。我见过因为发号器抖动导致线上短暂不可用的情况所以建议在发号器之上加一层号段缓存每个服务节点从中心拿一批号段在本地生成 ID用完了再取下一批减少中心依赖。这里特别提醒一点ID 生成时不要用简单的“订单号 自增序号”拼接。因为订单号本身可能连续拼接后取模可能导致同一订单下的券 ID 在某几个分片上聚集。雪花算法自带时间戳序列号的离散性实测下来取模分布非常均匀。5.2 库表分片规划与路由规则我们最终采用了固定逻辑分片 物理映射的两层路由方案。先在配置层面定好逻辑分片数逻辑分片总量32 个8 个物理库 x 每库 4 张表。券实例 ID 取模规则shard couponInstanceId % 32。根据 shard 计算出物理库和物理表public class CouponRouter { private static final int SHARD_COUNT 32; private static final int TABLE_COUNT_PER_DB 4; public Shard locate(Long couponInstanceId) { int shard (int) (couponInstanceId % SHARD_COUNT); String db cp_db_ (shard / TABLE_COUNT_PER_DB); String table coupon_instance_ (shard % TABLE_COUNT_PER_DB); return new Shard(db, table); } }这里有两个很容易踩坑的设计细节我说一下第一取模一定要对总逻辑分片数取模不要先对库数取模再对表数取模。否则后续调整物理库数量时同一个 ID 的路由结果会发生变化数据全部错乱。第二逻辑分片数一旦上线就不要再改了否则所有数据都必须重新 hash 迁移。扩容的正确姿势是保持逻辑分片数不变只调整物理映射关系。比如把 32 个逻辑分片从 8 个物理库迁移到 16 个物理库上每个库管的分片数变少了但券 ID 与逻辑分片的对应关系不变数据迁移粒度就是单个逻辑分片。表结构示意如下CREATE TABLE coupon_instance_0 ( id bigint NOT NULL COMMENT 券实例ID分片键, order_id bigint DEFAULT NULL COMMENT 来源订单ID, user_id bigint DEFAULT NULL COMMENT 领取用户ID, coupon_code varchar(32) NOT NULL COMMENT 对外券码, status tinyint NOT NULL DEFAULT 0 COMMENT 0未发放 1已发放 2已核销 3已过期, expire_time datetime NOT NULL, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_coupon_code (coupon_code) ) ENGINEInnoDB;注意这张表里不要放 user_id 的索引企图去支持用户维度查询。分库分表后单表内建 user_id 索引没有意义因为查询时没法定位到分片还是会全分片扫描。用户维度的查询需要通过冗余表解决下一章展开。5.3 发券流程预生成池 幂等绑定券码系统的发券通常分两步预先生成券实例用户领取时再绑定归属。这个节奏非常适合与分库分表结合。我们是这样设计的异步生成池提前把一批券实例写入各分片状态为“未发放”。券码系统流量是脉冲式的提前生成一批券能削峰避免活动开始时所有分片同时遭遇大批量 insert。用户领取动作变成 update用户点击领取时系统从池中取一张券实例 ID执行状态流转 update并绑定 user_id。幂等控制同一个用户同一个活动重复点击领取不能产生多张券。这个幂等可以在活动维度做也可以在券实例维度做。批量发放的伪代码逻辑如下Transactional public void issueCoupons(IssueRequest request) { // 1. 幂等校验同一个批次请求不能重复执行 if (issueLogMapper.exists(request.getBatchNo())) { return; } // 2. 从池子中获取一批未发放的券实例ID ListLong couponIds couponPoolService.acquire(request.getCount()); // 3. 按分片分组每个分片内批量更新 MapShard, ListLong grouped couponIds.stream() .collect(Collectors.groupingBy(id - router.locate(id))); for (Map.EntryShard, ListLong entry : grouped.entrySet()) { Shard shard entry.getKey(); ListLong ids entry.getValue(); couponMapper.bindOrderAndBatch(shard, ids, request.getOrderId(), request.getBatchNo()); } // 4. 写发放日志 issueLogMapper.insert(request); }批量更新时要把 ID 按分片分组每个分片内用update ... where id in (...)的方式操作避免一次跨分片的大事务。单分片内的 update 即使涉及几百行也是本地事务可控性高很多。还有一个经验批量 insert 或 update 的条数不要贪多每批 500 到 1000 条是比较安全的区间。我曾经试过一次性提交 5000 条结果单个分片的锁持有时间过长主从延迟涨到十几秒后面所有读流量都受影响。5.4 核销流程精准路由 行锁防重核销是券码系统里最不能出错的动作。用户拿券到店店员提交核销服务端必须保证这张券只被核销一次不能重复使用。用券实例 ID 分片后核销流程非常简单和可靠Transactional public ConsumeResult consume(Long couponInstanceId, Long userId, String consumeOrderId) { Shard shard router.locate(couponInstanceId); // 1. 分片内锁定该行 CouponInstance coupon couponMapper.selectForUpdate(shard, couponInstanceId); // 2. 状态校验 if (coupon null) { return ConsumeResult.fail(券不存在); } if (coupon.getStatus() ! Status.ISSUED) { return ConsumeResult.fail(券状态异常); } if (!coupon.getUserId().equals(userId)) { return ConsumeResult.fail(券不属于当前用户); } if (coupon.getExpireTime().isBefore(LocalDateTime.now())) { return ConsumeResult.fail(券已过期); } // 3. 状态流转 couponMapper.updateStatus(shard, couponInstanceId, Status.CONSUMED, consumeOrderId); return ConsumeResult.success(); }这个流程序列化地保证了路由只落到一个分片不存在跨分片事务。select for update在数据库层面锁住目标行并发核销同一张券时只有一个事务能通过状态校验。状态判断在 update 之前完成因为锁是行级的并发事务会排队执行不会出现两个事务同时读到“已发放”状态的情况。这里要注意select for update必须带分片键SQL 里要显式传入 couponInstanceId否则框架没法路由。这也是我对研发规范最强调的一点凡是操作券实例表的 DAO 方法第一个参数必须是券实例 ID任何接口入参都不允许出现“只传 user_id 然后内部查券 ID”的写法。6. 分库分表后的联动改造事务、分页、统计、对账6.1 跨分片事务用单分片事务加对账兜底按券实例 ID 分片之后有一个隐藏好处核销、领券这类最核心的写操作天然就是单分片事务不需要引入分布式事务。但发券流程里有一个环节可能是跨分片的券的预生成阶段一批券会散落在多个分片上。如果中途失败需要保证整个批次要么全部可用、要么全部回滚。这种场景我们不是用分布式事务硬扛而是采用“分批提交 状态机修正”。发券动作本身是幂等的申请失败的券可以重新激活或者人工补偿真正的一致性靠对账任务兜底每 5 分钟扫描一次发放日志对比实际券实例的状态发现不一致就触发补偿重试。我的经验是分库分表之后不要试图在应用层强行模拟跨分片强一致。分布式事务在极端流量下的性能和稳定性都很差性价比极低。更务实的做法是核心状态流转限制在单分片内完成。跨分片操作拆成多个单分片操作记录中间状态。配合消息或定时任务做最终一致性补偿。对账脚本定期核对堵住极端情况下的漏洞。6.2 用户券包高频查询用冗余表解决双维度矛盾券码系统有一个绕不开的双维度问题核销是按券维度券实例 ID进行的用户查券包却是按用户维度user_id进行的。这两个查询如果都落在同一张表上分片键就只能满足其中一方。券实例 ID 分片选型中核销是最核心的维度用户查询卷包可以通过冗余表来解决。我们的做法是增加一张按 user_id 分片的用户券包表CREATE TABLE user_coupon_0 ( user_id bigint NOT NULL, coupon_instance_id bigint NOT NULL, coupon_code varchar(32) NOT NULL, status tinyint NOT NULL, expire_time datetime NOT NULL, PRIMARY KEY (user_id, coupon_instance_id) ) ENGINEInnoDB;这张表的定位不是副本而是“面向用户维度的索引投影”。它的分片键是 user_id用户查券包时通过 user_id 直接定位到分片一次查询把所有券都拿到。券实例表和用户券包表之间存在异步同步用户领取成功后发送 MQ 消息异步写入 user_coupon 表。核销成功后同步更新 user_coupon 表的状态。定时对账任务对比两张表的数据防止不一致。异步同步会带来短暂的最终一致窗口但对用户来说领券后券包多出一张券延迟一两秒展示完全在可接受范围内。相比之下券包查询从全分片聚合变成单分片查询性能提升是肉眼可见的。6.3 分页查询要用游标统计查询走离线分库分表之后分页查询是一个大坑因为传统的limit offset, size在跨分片场景下必须先取全量再内存排序offset 越大性能越差。用户券包这种场景我们全部改成了游标分页-- 第一页 select * from user_coupon_0 where user_id ? and status 1 order by expire_time desc limit 20; -- 第二页传入上一页最后一条记录的 expire_time 或 id select * from user_coupon_0 where user_id ? and status 1 and (expire_time, coupon_instance_id) (?, ?) order by expire_time desc limit 20;游标分页的好处是每次查询都是恒定的单分片查询不管用户翻到第几页性能都不会衰减。至于后台的统计需求比如“查某个活动发放了多少张”“核销率是多少”“哪些券即将过期”这类 SQL 如果直接打到在线库即使分了库分了表也很恐怖。我们明确立了一条规矩在线库只服务用户链路接口统计报表全部走离线数仓。通过 binlog 或 MQ 把增量数据同步到 ClickHouse 或数据仓库T1 生成报表。如果需要实时大屏就用 Redis 或 ES 做预聚合而不是让业务 SQL 直接扫全部分片。7. 踩坑实录与常见问题速查7.1 最容易踩的5个坑下面我把改造过程中踩过、也看别人踩过的坑整理成速查表适合直接收藏现象根因解决方案某个分片磁盘和连接数告警按 order_id 分片大订单把全部券塞进同一分片改为按券实例 ID 分片并迁移历史数据核销接口 RT 波动大SQL 只带券码没带券实例 ID导致全分片路由DAO 强制入参携带分片键禁止无分片键查询发券后用户券包查不到券实例表和 user_coupon 表异步同步延迟接受最终一致加对账任务查询失败时回源主表补偿扩容后大量数据重分布分片数变更取模结果全部变化固定逻辑分片数只调整物理映射按逻辑分片迁移主从延迟大单次批量 insert 过多单分片长事务按分片分组每批 500 到 1000 条提交7.2 分片键必须进SQL这是研发规范不是建议改造过程中最让我头疼的不是技术方案而是团队习惯。很多同事写查询代码时习惯先查一个对象再查另一些数据比如先按 user_id 查到券实例 ID再拿券实例 ID 去查详情。这种写法在分库分表前没问题分库分表后就会变成二次路由不仅性能差还容易因为链路复杂出 bug。我在团队里做了一个硬性规定凡是操作分片表的 DAO 方法入参必须显式包含分片键。禁止出现“先查后查”式的隐式路由。同时把常用的路由计算封装成一个公共工具类在代码 review 时重点检查。用 ShardingSphere、MyCat 或自研框架的同学一定要在框架配置里把绑定表、分片算法配置好并且把分片键强制绑定到 SQL 解析器上让中间件在缺失分片键时直接报错而不是全分片扫描。这个报错机制很重要。宁可启动时报错也不要线上静默地全分片扫一遍。7.3 如果重新做一次我会怎么定方案这次改造给我最大的教训是分片键选型不能只看当下业务顺手要看系统未来两年的访问模型。如果现在有人让我重新设计一套券码系统我会按这个顺序来列出未来两年最高频的 20 条 SQL标出访问频次和关键性。找出这 20 条 SQL 里出现频次最高的公共条件字段那大概率就是分片键。对高频查询中无法用主分片键覆盖的维度设计冗余表或者索引投影而不是让查询迁就分片键。设计好固定逻辑分片和物理映射关系为未来扩容留好路。定下规矩所有操作分片表的 DAO 必须携带分片键没有商量余地。事后复盘的时候我发现当初选 order_id 的一个隐晦原因是“总觉得从订单进入券列表很自然”。但系统设计不能靠自然联想要靠数据访问路径。券的生命周期比订单长、券的核销比订单查询频繁、券的数据颗粒度比订单小这些才是决定分片键的关键因素。我个人现在的体会是券码系统里的每张券都应该被当成一个独立的长期资产来管理而不是订单的一个临时附件。分片键选型其实就是在回答一个问题这个系统最核心的原子操作是什么在这个场景里答案是核销一张券而一次核销只有一个确定的主语就是券实例 ID。
