很多 .NET 开发者在业务体量涨到一定阶段后,都会遇到同一个问题:数据库成了整个系统的短板。不是 SQL 写得不好,不是索引没建对,而是在数据量达到千万级、亿级之后,单库单表已经承载不住读写压力。这时候,分库分表就成了绕不开的话题。
但 .NET 生态的分库分表资料,确实比 Java 生态少很多。Java 有 ShardingSphere 这样成熟的框架,文档和案例都很丰富;.NET 这边虽然也有不少方案,但中文资料相对零散,真要落地时,很容易被分片键选择、跨分片分页、联表查询、数据合并这些问题卡住。
这篇文章我会从实际项目出发,讲清楚几件事:分库分表到底解决了什么;数据量大了之后,分页、联表查询、数据拆分合并到底怎么处理;在 AI 辅助编程流行的今天,怎么让 AI 帮我们更快地完成方案设计与代码落地。文章里的代码和配置都偏向实战,建议收藏备用。
先给出我的明确判断:分库分表不是银弹,没有到“不得不做”的规模,尽量不要碰。但如果业务确实到了这一步,那这篇文章可以帮你少走很多弯路。
1. 高并发场景下,数据库瓶颈究竟出在哪
先说一个真实存在的开发场景。假设你负责一个订单系统,上线初期每天几千单,一张orders表轻松搞定,索引建得合理,查询响应时间都在几十毫秒以内。但业务增长后,订单量从每天几千涨到几十万,历史数据不断累积。半年之后,orders表里有了上千万行数据。这时候你会陆续发现:
- 大范围查询耗时从几十毫秒涨到几百毫秒,甚至秒级;
- 写入请求一多,数据库锁竞争让整体吞吐量大幅下降;
- 夜间统计任务和白天交易高峰期重叠时,数据库连接数和 CPU 双双告警;
- 即使建了索引,索引体积变大之后,内存命中率下降,磁盘 IO 成为新瓶颈。
很多人第一反应是加索引、加缓存、做读写分离。这些手段在数据量还没到极端规模的时候确实有效,但当单表数据量持续膨胀,索引体积变大,缓存命中率下降,读写分离也只能缓解读压力,写压力依然集中在主库,单表瓶颈依然存在。
分库分表的本质是“拆”。把一个大库拆成多个库,把一张大表拆成多张小表,让每一份数据都落在更小的物理集合里。这样一来,单表的数据量降下来了,单库的负载降下来了,系统整体的吞吐能力也就上去了。
这个思路听起来很简单,但真正落地时,会有大量细节问题。这也是为什么很多团队“想到”分库分表和“做成”分库分表之间,常常隔着好几个月的加班时间。
2. 分库分表的两种拆分路径:垂直与水平
分库分表通常分为两种路径:垂直拆分和水平拆分。很多新手容易混淆,这里先用最通俗的方式讲清楚。
2.1 垂直拆分
垂直拆分是按业务模块拆,把一张宽表或者一个库里的不同业务拆分到不同库。典型场景是“用户库、订单库、商品库”拆开。原来是单体数据库包含所有表,现在按照业务域拆成专门的服务和数据库。
垂直拆分的优点是:
- 业务边界清晰,不同团队可以独立维护自己的库;
- 单库的连接数、IO、锁竞争压力立刻下降;
- 为微服务化打基础。
缺点是:
- 原来一个事务里可以同时操作订单和商品表,拆库后事务变成了分布式事务;
- 跨业务查询不能再用 SQL join,必须走应用层聚合或者接口调用。
很多微服务项目做“数据库拆分”时,指的就是垂直拆分。
2.2 水平拆分
水平拆分是按数据行拆,把同一张表的数据按某个规则分散到多张结构相同的表,或者多个结构相同的库。比如用户 ID 取模,把订单数据分散到orders_0、orders_1、orders_2、orders_3四张表里。
水平拆分解决的是单表数据量过大、单库写入吞吐不足的问题。它不会像垂直拆分那样改变业务边界,但会把单表的 CRUD 变成“先路由、再操作”。每一条 SQL 进来,先要判断应该落到哪张表、哪个库。
两张方式不是对立的。在实际大型项目中,通常会先做垂直拆分,让每个业务域独立,再对核心的订单表、流水表做水平拆分。下面这张表可以帮助你快速判断:
| 拆分方式 | 解决的核心问题 | 典型场景 | 主要成本 |
|---|---|---|---|
| 垂直拆分 | 单库连接数、业务耦合、团队协作冲突 | 微服务改造、业务域隔离 | 分布式事务、跨业务聚合 |
| 水平拆分 | 单表数据量、单库写入吞吐瓶颈 | 订单表、流水表、日志表 | 分片路由、扩容、跨分片查询 |
这里要特别提醒:垂直拆分相对容易,因为本质是“数据库架构调整 + 代码调用方式调整”;水平拆分难度更大,因为它直接改变 SQL 的执行路径,所有查询都要经过一层分片路由。
3. .NET 生态分库分表选型:中间件还是自研
在 .NET 生态里做分库分表,目前没有像 Java ShardingSphere 那样的“全家桶”方案,但可以选择的路线并不少。我按实际项目的经验把选型分为三类。
3.1 使用通用数据中间件
比如 ShardingCore,这是 .NET 生态里比较有代表性的分库分表中间件。它基于 EF Core 扩展,支持按时间、按哈希等方式分片,也支持跨分片分页和聚合查询。优点是接入成本相对低,和 EF Core 一起使用时,大部分 LINQ 查询不用改。
这类中间件适合大多数业务系统,尤其是已经使用 EF Core 的团队。需要注意的是,任何中间件都有能力边界,框架能帮你解析查询、路由到分片,但它不会帮你设计分片键,也不解决所有 SQL 兼容问题。
3.2 代理层方案
类似 ShardingSphere-Proxy 的方式,在应用和数据库之间加一层代理。应用连接的还是普通数据库地址,代理层负责解析 SQL、路由到后端的多个数据库实例。
代理层方案的优点是:
- 应用基本无感知,SQL 还是原来的 SQL;
- 支持多语言接入,不绑定 .NET 技术栈。
缺点也很明显:
- 引入独立部署组件,维护成本变高;
- SQL 解析层会带来额外延迟;
- 复杂 SQL 兼容性有限。
3.3 自研数据访问层
如果业务场景比较简单、分片规则非常固定,也可以自己在仓储层做分片路由。比如封装一个OrderRepository,写入时对订单号取模选择表名,查询时按用户 ID 路由。
自研方案的优点是灵活、轻量、没有黑盒,缺点是从零实现要考虑到分页、事务、扩容、跨分片聚合等大量问题。如果团队对分片规则非常有把握,而且业务不会频繁变化,这种做法可控性反而最高。
从我的判断来看:中小团队和业务快速迭代的项目,优先考虑基于 ORM 的中间件;大型团队、多个技术栈并存时,代理层更合适;分片规则稳定且简单的系统,自研也没有问题。
4. 分片键设计:三种常见分片策略与实现
分片键是分库分表的核心。分片键选得好不好,直接决定系统的扩展能力和查询效率。
分片键设计总的原则是:让大多数查询能直接命中某一个分片,避免全分片扫描。
4.1 哈希取模分片
这是最常用的策略。选择业务上分布均匀的字段作为分片键,比如用户 ID、订单号。对分片键计算哈希值,然后对分片数量取模,得到目标分片号。
哈希取模的优点是数据分布均匀,缺点是扩容时数据迁移量大。原来 4 个分片扩到 8 个分片,几乎所有数据都可能要重排,这时通常配合一致性哈希来解决。
下面是一个可以放到工具类里的取模路由逻辑,用 MD5 计算稳定哈希值,避免依赖运行时的GetHashCode在不同进程间结果不一致的问题:
// 文件路径:Sharding/ShardingStrategy.cs using System.Security.Cryptography; using System.Text; public static class ShardingStrategy { /// <summary> /// 基于稳定哈希的分片路由 /// </summary> /// <param name="shardingKey">分片键,如用户ID</param> /// <param name="shardCount">分片总数</param> /// <returns>分片索引,从 0 开始</returns> public static int HashModShard(string shardingKey, int shardCount) { using var md5 = MD5.Create(); var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(shardingKey)); var value = BitConverter.ToUInt64(hashBytes, 0); return (int)(value % (ulong)shardCount); } public static int HashModShard(long shardingKey, int shardCount) { return HashModShard(shardingKey.ToString(), shardCount); } }这段代码的核心是用 MD5 把字符串分片键转换成高散列的数值,再对分片总数取模。使用场景是:分片键类型不确定,或者需要兼容字符串和数值类型时,统一走字符串重载即可。
4.2 范围分片
按时间范围或 ID 范围分片,比如orders_202401、orders_202402,每个月一张表。这种策略实现简单,扩容方便,非常适合日志、流水类数据,但需要处理热点问题:当月数据总是集中在最新一张表。
范围分片的建表思路是在写入时根据业务时间拼接表名:
// 文件路径:Sharding/TableNameBuilder.cs public static class TableNameBuilder { public static string BuildByMonth(DateTime time, string tablePrefix) { return $"{tablePrefix}_{time:yyyyMM}"; } }使用方式示例:
var tableName = TableNameBuilder.BuildByMonth(DateTime.Now, "orders"); // 输出:orders_202512范围分片和哈希取模可以组合使用。常见做法是:先按用户 ID 取模分库,再按时间分表。这样既解决了单用户数据量大时的查询效率问题,也解决了单库热点问题。
4.3 映射表分片
如果分片键无法直接从业务数据中确定,或者业务上需要通过多个字段来定位,可以加一张“分片映射表”,记录业务主键和分片编号的关系。
这种方式最灵活,但会额外引入一次查询开销,而且映射表本身也可能变成瓶颈。实际项目中,映射表通常配合缓存使用,减少数据库查询次数。
4.4 分片键选型建议
分片键不要选“选择起来很别扭”的字段,也不要只考虑写入场景。下面几条原则比较实用:
- 优先选择查询频率最高的字段,比如用户 ID;
- 优先选择值分布均匀的字段,避免订单状态这种只有几个取值的字段;
- 优先选择不可变字段,如果业务主键本身会变,分片键也会跟着变,数据迁移成本很高;
- 秒杀、抢购这类写热点极高的系统,要考虑按商品拆,而不是按用户拆。
5. 用 AI 辅助生成分库分表方案的思路
标题里提到 AI 辅助落地,这里展开说一下。AI 辅助分库分表不是一个“一键完成”的黑魔法,它更像一个快速生成草稿和扫描盲区的工具。用得好,能省掉大量写模板代码的时间,但最终决定权仍然在架构师手里。
5.1 AI 能帮忙做的四件事
第一,生成分片算法原型。把分片需求描述清楚,AI 可以根据取模、一致性哈希、时间范围等策略,快速生成一段可运行的 C# 代码,比手写再改 bug 要快很多。
第二,分析现有 SQL 和 LINQ 查询在分片后的兼容性。把查询语句贴给 AI,让它判断哪些字段会成为分片键、哪些查询会变成跨分片查询,并给出改写建议。
第三,生成数据迁移和校验脚本。分库分表上线前要把存量数据从单表迁移到多个分片,AI 可以帮你生成迁移脚本、校验脚本、异常数据对账脚本。
第四,生成幂等的初始化脚本。分片表结构需要批量创建,AI 可以帮你生成循环建表 SQL。
5.2 一个可复用的提示词模板
下面这个提示词模板比较通用,可以收藏:
我有一张订单表 orders,目前单表数据量约 5000 万行,后续仍在快速增长。 订单表主要通过 user_id 查询用户订单,也支持按 order_no 查询单条订单。 请帮我设计一个水平分库分表方案,并输出: 1. 分片键选择和分片策略建议; 2. 分库分表后的实体类和仓储层代码,使用 C# / EF Core; 3. 4 个分片的建表 SQL; 4. 使用 user_id 分页查询订单的示例代码; 5. 使用 order_no 精确查询时的处理方案。注意,AI 生成的代码仍然需要人工 review。尤其是分片策略、事务边界、异常处理这些关键逻辑,不要让 AI 直接决定。
5.3 AI 辅助落地的正确姿势
我比较推荐的做法是:先自己理解分库分表的核心原理,再让 AI 帮忙加速。如果对分片路由、跨分片分页这些概念不熟,AI 生成的代码很难判断对不对。
AI 还有一个重要用途是生成完整的迁移检查清单。比如:
- 存量数据有没有全部迁移;
- 分片键和分片算法是否匹配;
- 旧表是否还需要保留只读备份;
- 灰度期间双写方案。
这些清单让 AI 生成初版,再用项目实际场景去校验,效率会高很多。
6. 基于 ShardingCore 的完整项目落地示例
下面进入实操环节。我们用一个订单查询接口作为例子,演示从创建 WebAPI 项目到配置分库分表、编写查询接口的完整过程。
6.1 创建 WebAPI 项目与安装依赖
首先创建一个 ASP.NET Core WebAPI 项目:
dotnet new webapi -n ShardingDemo cd ShardingDemo然后添加 NuGet 依赖。这里以 ShardingCore 为例,对应 EF Core 版本请根据当前项目实际情况选择,不要盲目装最新版。
dotnet add package ShardingCore dotnet add package Microsoft.EntityFrameworkCore.SqlServer如果使用 MySQL,把第二个包换成Pomelo.EntityFrameworkCore.MySql或者MySql.EntityFrameworkCore,以你的数据库类型为准。
6.2 配置多数据源
在appsettings.json中配置多个库的数据源。这里我们拆成两个库,每个库里都有相同的表结构。
{ "ConnectionStrings": { "Default": "Server=localhost;Database=order_db;Uid=root;Pwd=123456;", "OrderDb0": "Server=localhost;Database=order_db_0;Uid=root;Pwd=123456;", "OrderDb1": "Server=localhost;Database=order_db_1;Uid=root;Pwd=123456;" }, "Sharding": { "ShardCount": 2 } }这里ShardCount是自定义配置项,用来告诉代码分片总数。具体连接串按你自己的环境修改。
在Program.cs中注册 DbContext 和 ShardingCore:
// 文件路径:Program.cs using Microsoft.EntityFrameworkCore; using ShardingCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); // 注册 DbContext builder.Services.AddDbContext<OrderDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); // 注册 ShardingCore,伪代码,具体 API 以当前版本官方文档为准 builder.Services.AddShardingDbContext<OrderDbContext>() .AddShardingTableConfigure(op => { // 配置分片实体和分片策略 }); var app = builder.Build(); app.UseAuthorization(); app.MapControllers(); // 启动时初始化分片表 using (var scope = app.Services.CreateScope()) { var context = scope.ServiceProvider.GetRequiredService<OrderDbContext>(); // 确保数据库已创建,生产环境请使用迁移脚本 context.Database.EnsureCreated(); } app.Run();需要说明的是,ShardingCore 的 API 在不同版本之间有一些差异,上面AddShardingDbContext这种写法是框架早期的典型用法。在你落到自己系统里时,一定要以当前所用版本的官方 README 为准。
6.3 定义实体与分片映射
定义订单实体。这里我们选择UserId作为分片键,分表规则是按用户 ID 取模。
// 文件路径:Models/Order.cs using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; [Table("orders")] public class Order { [Key] public long Id { get; set; } public long UserId { get; set; } public string OrderNo { get; set; } public decimal Amount { get; set; } public DateTime CreateTime { get; set; } }在 DbContext 中配置分片映射。这里的关键是告诉框架“这个实体对应的分片表名规则”,以及“分片键是哪个属性”。
// 文件路径:Data/OrderDbContext.cs using Microsoft.EntityFrameworkCore; public class OrderDbContext : DbContext { public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options) { } public DbSet<Order> Orders { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // order 实体按 UserId 分片,分片表命名规则为 orders_{0} modelBuilder.Entity<Order>() .HasShardingTable(o => o.UserId, "orders_{0}"); // 索引优化 modelBuilder.Entity<Order>() .HasIndex(o => new { o.UserId, o.CreateTime }); } }HasShardingTable(o => o.UserId, "orders_{0}")这行的意思是:对UserId做分片计算,分片表名以orders_开头,后面接分片编号。实际表就是orders_0、orders_1。
6.4 编写查询接口
接下来写一个查询接口,支持按用户 ID 分页查询订单列表,以及按订单号查询单条订单。
// 文件路径:Controllers/OrderController.cs using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; [ApiController] [Route("api/orders")] public class OrderController : ControllerBase { private readonly OrderDbContext _dbContext; public OrderController(OrderDbContext dbContext) { _dbContext = dbContext; } /// <summary> /// 按用户 ID 分页查询订单 /// </summary> [HttpGet("user/{userId}")] public async Task<IActionResult> GetByUser(long userId, int pageIndex = 1, int pageSize = 20) { if (pageIndex < 1) pageIndex = 1; if (pageSize < 1 || pageSize > 100) pageSize = 20; var total = await _dbContext.Orders .Where(o => o.UserId == userId) .LongCountAsync(); var items = await _dbContext.Orders .Where(o => o.UserId == userId) .OrderByDescending(o => o.CreateTime) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync(); return Ok(new { total, items }); } /// <summary> /// 按订单号查询,使用映射缓存或广播表方案 /// </summary> [HttpGet("no/{orderNo}")] public async Task<IActionResult> GetByOrderNo(string orderNo) { // 这里先演示逻辑,实际场景中如果有 user_id 字段一起传入,才能直接定位分片 var order = await _dbContext.Orders .FirstOrDefaultAsync(o => o.OrderNo == orderNo); if (order == null) { return NotFound(); } return Ok(order); } }这里有两个非常重要的工程细节。
第一个是分页查询。在分库分表环境下,Skip/Take并不是简单地在单表上执行,而是会先在每个分片中分别取offset + pageSize行,然后合并排序,再在内存中做二次分页。这样实现逻辑是正确的,但当pageIndex非常大时,每个分片都要拿出大量数据到内存合并,性能会急剧恶化。应对思路是严格控制分页深度,禁止用户随便访问第 10000 页。
第二个是按订单号查询的问题。如果订单号不是分片键,直接查询会触发全分片扫描。如果业务上经常需要按订单号查询,建议维护一张order_no -> user_id的映射表,或者把用户 ID 冗余在订单号里。这部分内容在第八章里会展开讲。
6.5 启动项目与验证
启动项目:
dotnet run访问接口,例如:
GET http://localhost:5000/api/orders/user/10001?pageIndex=1&pageSize=20如果返回了数据库中的订单数据,说明通过分片路由查询成功。
如果你希望直接看到 SQL 路由到哪个分片,可以打开 EF Core 的日志。在appsettings.json中配置:
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } } }这样能在控制台看到生成的 SQL 和对应的表名。如果发现 SQL 查的是orders_0而不是全部分片,说明分片路由生效了。
7. 大数据量分页优化:跨分片分页的正确姿势
分页查询是分库分表后最容易被忽视的问题。单表分页时,OFFSET 100000, 20虽然慢,但往往还能接受;分库分表后,同样的分页逻辑会被放大 N 倍。
假设你有 4 个分片,要查第 5000 页,每页 20 条。那每个分片都要取出前 100000 条数据,到内存里排序后再取第 5000 页的 20 条。四个分片就相当于一次性处理了 40 万条数据,而且每次翻页都要重复这个过程。
解决方案通常有三种。
7.1 禁止深分页
最简单也最常用。前端只允许查看前 2000 条,超过 100 页的查询直接拒绝。这种方式适合普通后台管理页面。
7.2 游标分页
游标分页不计算偏移量,而是根据上次查询的最后一条记录来取下一页。比如按CreateTime和Id排序,下一页的查询条件就是:
var items = await _dbContext.Orders .Where(o => o.UserId == userId && (o.CreateTime < lastCreateTime || (o.CreateTime == lastCreateTime && o.Id < lastId))) .OrderByDescending(o => o.CreateTime) .ThenByDescending(o => o.Id) .Take(pageSize) .ToListAsync();游标分页的好处是每一页的数据量是固定的,不会随着页码增大而膨胀,性能非常稳定。缺点是前端只能点击“下一页”,不能直接跳转到第 N 页。
7.3 基于 Redis 缓存的总数和页码索引
对于不要求实时精确统计的场景,可以定期把分片内的数据分页索引同步到 Redis,用 ZSet 存储排序字段,分页直接在 Redis 中完成。这种方式适合榜单、热门列表这类读多写少的数据。
我的建议是:新系统首选游标分页,老系统尽量限制分页深度。深分页在分库分表环境里属于典型的“正确但很贵”的方案。
8. 联表查询与数据拆分合并实战
联表查询在单库时代非常简单,一个 join 就搞定。分库分表之后,如果两个表都不在同一个分片里,join 就会变成灾难。下面分三种情况说明。
8.1 第一种:广播表/字典表
用户表、商品表、地区表这类数据量不大、变动不频繁的表,可以冗余到每一个分片库里。这样订单表在本地分片内直接 join 商品表、用户表,不需要跨库查询。
这种表在中间件里通常叫“广播表”或“字典表”。实现上就是在每一个分库中创建相同结构的表,写入时同步到所有分库,读取时只访问本分片。
8.2 第二种:同分片字段冗余
如果订单表需要关联用户表,而用户表又无法做成广播表,可以考虑把用户表中订单查询常用的字段冗余到订单表里,比如user_name、user_mobile。这样查询时不需要 join 用户表,直接从订单表取字段。
这就是典型的“反范式”设计。分库分表之后,数据库层的 join 能力被削弱,应用层和存储层要通过冗余字段换取查询效率。
8.3 第三种:应用层内存聚合
如果订单要关联商品,商品数量很大没办法做广播表,订单表里也没有商品的其他冗余字段,那就只能先把两边数据都查出来,在应用内存中做合并。
// 文件路径:Services/OrderAggregateService.cs public class OrderAggregateService { private readonly OrderDbContext _dbContext; private readonly IProductService _productService; public OrderAggregateService(OrderDbContext dbContext, IProductService productService) { _dbContext = dbContext; _productService = productService; } public async Task<List<OrderDetail>> GetOrderDetailsAsync(long userId, int pageIndex, int pageSize) { // 第一步:查询订单 var orders = await _dbContext.Orders .Where(o => o.UserId == userId) .OrderByDescending(o => o.CreateTime) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync(); if (orders.Count == 0) { return new List<OrderDetail>(); } // 第二步:获取商品 ID 集合 var productIds = orders .Where(o => o.ProductId.HasValue) .Select(o => o.ProductId.Value) .Distinct() .ToList(); // 第三步:调用商品服务批量查询 var products = await _productService.GetByIdsAsync(productIds); // 第四步:在内存中合并订单与商品信息 var detailList = orders.Select(order => { var product = products.FirstOrDefault(p => p.Id == order.ProductId); return new OrderDetail { OrderId = order.Id, OrderNo = order.OrderNo, ProductName = product?.Name, ProductPrice = product?.Price, CreateTime = order.CreateTime }; }).ToList(); return detailList; } }这个示例的关键点是:分库分表环境下,跨分片的关联查询尽量拆成“多次查询 + 内存合并”,而不是试图写一条跨分片 join 让框架硬撑。
应用层聚合虽然多了一步网络调用或内存操作,但好处是每个查询都可控,可以针对每类数据分别做缓存、做分页、做降级,比让数据库做跨分片 join 稳定得多。
9. 常见问题与排查思路
分库分表落地过程中,我见过很多团队踩在相同的坑上。下面整理成表格,方便排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 查询结果不全 | 分片键判断失误,部分数据路由到错误分片 | 检查分片算法,对比分片前后的数据量 | 修正分片路由规则,重新迁移数据 |
| 每次查询都很慢 | 查询未携带分片键,触发了全分片扫描 | 查看 SQL 日志,确认是否路由到单分片 | 根据业务加入分片键条件,或维护映射表 |
| 深分页触发 OOM | 跨分片Skip/Take拉取大量数据到内存 | 检查分页深度和分片数量 | 改用游标分页或限制分页深度 |
| 事务失效 | 一个事务涉及多个分片,中间件不支持跨分片事务 | 检查事务上下文和报错日志 | 按分片键设计聚合根,避免跨分片事务 |
| 建表 SQL 重复执行报错 | 迁移脚本在多个分片重复执行 | 检查迁移脚本日志 | 增加幂等判断,比如IF NOT EXISTS |
| 接入中间件后 LINQ 查询报错 | 部分查询语法中间件不支持 | 查看异常堆栈和解析日志 | 改写查询,去掉不支持的语法,分步查询 |
| 扩容后部分数据查不到 | 取模分母变化,重新哈希后路由失效 | 对比新老路由结果 | 双写迁移,或使用一致性哈希 |
这里最容易翻车的是第一条和第七条。哈希取模看起来很直观,但扩容时冷数据不会自动跟随新的取模结果移动。一旦你把分片数从 4 改成 8,老数据还留在原来的分片,新查询按 8 取模却去新分片找,结果就是“数据消失了”。
这类问题的系统化解法是预留足够分片数,或者一开始就使用一致性哈希。如果已经上线了,就只能做数据迁移,不能简单改配置。
10. 最佳实践与工程建议
分库分表项目里,工程规范比代码本身更重要。下面这几条建议来自实际项目教训,值得写进团队规范。
10.1 分片键必须进入所有核心查询
在代码评审时,我会重点检查每一个订单查询接口,看它的查询条件里是否携带了分片键。如果没有,要么是功能设计有问题,要么是需要走映射表。设计接口时,尽量通过路径参数、Token、Header 传递当前用户 ID,保证分片键可用。
10.2 所有分片表结构变更要脚本化管理
分片表不是一张表,是 N 张表。任何ALTER TABLE都不能靠手工在单个库执行,而要通过脚本批量执行,并且脚本要具备幂等性。
-- 示例:对所有分片订单表增加字段 -- 生产环境请先备份,再在测试环境验证 ALTER TABLE orders_0 ADD COLUMN buyer_remark VARCHAR(200) NULL; ALTER TABLE orders_1 ADD COLUMN buyer_remark VARCHAR(200) NULL;更推荐的方式是用迁移工具管理,而不是手写脚本。跑完迁移后,要检查每个分片的表结构是否一致。
10.3 日志里必须打印分片路由信息
排查分库分表问题时,最头疼的就是不知道一条 SQL 到底落在了哪个分片。建议在开发环境开启 SQL 日志,在生产环境至少记录慢查询日志,并打印分片表名。
10.4 监控和告警要覆盖分片维度
不要只监控整个数据库集群的总连接数和总 QPS,要看每个分片库的负载是否均衡。如果发现某个分片的数据量明显偏大,就要检查分片键的取模分布。
10.5 上线前必须演练数据迁移回滚
分库分表上线不是“跑一次迁移脚本”就结束了。要提前准备回滚方案,比如保留旧表的只读备份、记录迁移断点、做数据对账。一般会通过双写的方式灰度:先把新数据同时写到单表和新分片,确认稳定后再把旧数据批量迁移,最后切换读流量。
11. 结束语:下一步该怎么走
分库分表的核心不是把表拆开,而是拆开之后,系统依然能正确、高效地完成数据读写。它真正考验的是架构师对分片键、查询模式、数据分布和数据迁移的掌控力。
如果你是刚开始接触这个方向的 .NET 开发者,下一步可以这么做:
- 先用一个简单的 WebAPI 项目,实现按用户 ID 取模的路由逻辑,把数据写入不同的表;
- 再尝试在中间件环境里配置多数据源,跑通分页查询和联表查询;
- 然后写一套基于映射表的分片方案,对比不同查询场景下的性能差异;
- 最后再考虑数据迁移、双写、灰度发布这些工程化内容。
数据库没有万能的架构,分库分表也不是所有系统都必须经历的一步。很多业务通过合理的索引、缓存、读写分离就能支撑到足够大的规模。只有当单表数据量和写入吞吐成为确定性瓶颈时,再启动分库分表改造,才是最稳妥的节奏。