简介:面向 Windows Forms 开发者的一份分页控件与 SQL 数据库配合使用的源码示例,适合需要在 DataGridView 等表格界面中分批加载、浏览大量数据的桌面应用场景,也适合初中级开发者参考分页查询、数据绑定与界面交互的实现思路。压缩包共 65 个文件,约 218KB,以 C# 源码、窗体资源、项目文件及配置资源为主,另含少量程序集、示例图片和说明文本,可直接打开解决方案查看控件封装、窗体调用和数据库访问代码。已有 837 人学习,说明该示例在同类分页控件资料中具有一定参考价值。内容围绕分页控件封装、WinForm 窗体集成、ADO.NET 数据访问、分页查询、导航按钮交互、搜索排序与异常处理等要点展开,读者可从中梳理分页控件的设计结构、页码切换与每页条数调整逻辑,并借鉴数据绑定、查询性能优化和界面布局的写法,用于自己的 WinForm 数据管理模块。
1. WinForm 分页控件 + SQL 数据库:老后台里最值得抄回来的一个轮子
上周帮朋友看一个仓储后台,订单表四十多万行,界面一打开就把整张表绑进 DataGridView,鼠标点查询要愣十几秒,任务管理器里内存直接冲到七八百兆。后来把查询拆成「一次只取一页」,前面放一个自己画的 WinForm 分页控件,背后接 SQL 数据库的 OFFSET/FETCH 查询,同一个页面从卡死变成秒开,代码量反而少了一截。
这份资源就是一个可以直接拖进窗体复用的分页控件,带首页、上一页、下一页、末页、跳页输入框和每页条数下拉,对外只抛一个 PageChanged 事件,其余交给宿主窗体自己去查库。它不绑定任何 ORM,也不假设你用的是 DataGridView 还是 ListView,适合正在维护 .NET Framework 桌面项目、想给列表页快速补上分页的那批人;如果你手上正好有一张几十万行的 SQL Server 表,这套东西基本能当天接进去。
2. 工程结构拆解:控件、模型、仓储三层各管什么
先看目录,这个项目的分层不复杂,但对一个桌面小工具来说边界划得挺清楚:控件只管页码,模型只管参数,仓储只管 SQL,三块互不依赖对方的实现细节。很多人写分页控件容易犯的毛病是把 SqlConnection、DataSet 全塞进 UserControl 里,结果换个表就得改控件,最后控件越改越不敢动。这份代码从一开始就把这条路堵死了。
2.1 目录结构与每个文件的职责
WinFormPager/ ├─ Controls/ │ ├─ PagerControl.cs # 分页控件主逻辑:页码状态、按钮事件、对外抛 PageChanged │ ├─ PagerControl.Designer.cs # 设计器生成的布局代码,按钮和输入框都在这儿 │ └─ PagerControl.resx # 控件资源,暂时是空的,留着给图标用 ├─ Models/ │ ├─ PageRequest.cs # 分页请求参数:页码、每页条数、排序字段、关键字 │ ├─ PagedResult.cs # 分页结果包装:当前页数据 + 总条数 │ └─ PageChangedEventArgs.cs # 控件事件的参数对象 ├─ Data/ │ ├─ DbHelper.cs # ADO.NET 薄封装,负责开连接、执行、关连接 │ └─ OrderRepository.cs # 订单表的仓储,分页查询和 COUNT 都在这儿 ├─ Forms/ │ ├─ MainForm.cs # 示例窗体,演示怎么接线 │ └─ MainForm.Designer.cs ├─ App.config # 连接字符串 └─ WinFormPager.csproj| 文件 | 职责 | 改动频率 |
|---|---|---|
| PagerControl.cs | 维护当前页、总页数、按钮可用状态 | 低,做通用组件时基本不动 |
| PageRequest.cs | 承载页码和每页条数,向下传到仓储 | 中,加筛选条件时在这里扩 |
| OrderRepository.cs | 拼分页 SQL、执行、返回强类型集合 | 高,换表就得改 |
| DbHelper.cs | 连接管理、参数化命令 | 低 |
| MainForm.cs | 订阅事件、调仓储、绑定表格 | 高,每个业务页面都要写一遍 |
这张表其实说明了改造时该往哪儿下手:要换数据源,动 OrderRepository;要改交互样式,动 PagerControl;要加业务筛选,动 PageRequest 和 MainForm。分清楚这三件事,后面接手的人就不会一上来就翻控件源码。
2.2 技术栈锁在 .NET Framework 4.x 加 ADO.NET 的原因
这类项目通常跑在客户内网的办公机上,操作系统从 Win7 到 Win10 都有,装个 .NET Framework 4.5 还算现实,装 .NET 6 运行时就得跟运维扯皮。所以技术选型上没有太多纠结:C# + WinForms + .NET Framework 4.5 起步,数据库访问直接用 ADO.NET,不引 Entity Framework,也不引 Dapper。
不引 ORM 的另一个原因是分页 SQL 需要精细控制。ORM 生成的分页语句在数据量大、排序字段复杂的时候经常给出意料之外的执行计划,而手写 SQL 你能明确告诉数据库「先按 OrderId 倒序,再取第 41 到 60 行」。代价是要自己处理参数化和 DBNull,这部分代码量不大,DbHelper 里几十行就够了。
数据库这边按 SQL Server 2008 R2 起步考虑,所以分页语句不能只写 OFFSET/FETCH。2008 R2 不认识这个语法,得退回 ROW_NUMBER() OVER() 的子查询写法;2012 以后才推荐用 OFFSET/FETCH,写法短、执行计划也更稳。代码里给了两条路径,用一个枚举切换,具体在 3.2 节展开。
提示:如果你的客户环境已经统一到 SQL Server 2012 及以上,直接把 ROW_NUMBER 那条分支删掉,代码会清爽不少;留着它主要是为了兼容那些还在跑 2008 R2 的老库。
2.3 控件布局与对外事件契约
PagerControl 继承 UserControl,高度固定 36 像素,Dock 属性设成 Bottom,拖进窗体后自动贴在底部。内部从左到右依次是:总记录数 Label、每页条数 ComboBox、首页、上一页、页码 Label、下一页、末页、跳页 TextBox、跳转按钮。全部放在一个 FlowLayoutPanel 里,靠右对齐,窗口拉伸时不会错位。
对外的契约只有四个属性和一个事件:
public int PageIndex { get; } // 当前页码,从 1 开始 public int PageSize { get; } // 每页条数,默认 20 public int RecordCount { get; } // 总记录数,由宿主回填 public int PageCount { get; } // 总页数,内部算出来 public event EventHandler<PageChangedEventArgs> PageChanged; // 宿主查完数据后调用,把总数和当前页码回填给控件 public void SetPageInfo(int recordCount, int pageIndex);关键在于控件不持有数据。它不知道你查的是订单还是用户,也不知道数据是 DataTable 还是 List ,它只负责算「现在在第几页、总共几页、哪些按钮该灰掉」,然后把「用户想翻到第几页」通过事件丢出去。宿主窗体收到事件,自己去调仓储、自己去绑表格、查完再调 SetPageInfo 把新的总数回填。这么一个来回,控件和业务就彻底解耦了。
PageChangedEventArgs 里带三个字段:PageIndex、PageSize、Reason。Reason 是个小设计,值为 ButtonClick、Jump、PageSizeChange 之一。为什么需要它?因为「改每页条数」和「点下一页」在业务上往往处理不一样,前者通常要回到第一页,后者不需要。把动作类型传出去,宿主就不用去猜。
3. 核心代码走查:点一次「下一页」背后跑了几条 SQL
这一章按一次真实点击的顺序往下走:用户点「下一页」→ 控件算出目标页码 → 抛事件 → 窗体调仓储 → 仓储发两条 SQL(一条 COUNT、一条取数)→ 结果回到窗体绑表格 → 回填总数。链路不长,但每一环都有容易写错的地方。
3.1 分页参数模型:PageRequest 与 PagedResult
public class PageRequest { private int _pageIndex = 1; private int _pageSize = 20; // 页码强制夹在 1 以上,防止上层传 0 或负数导致 OFFSET 计算出负值 public int PageIndex { get { return _pageIndex; } set { _pageIndex = value < 1 ? 1 : value; } } // 每页条数给个上限,避免有人手输 100000 把内存打爆 public int PageSize { get { return _pageSize; } set { _pageSize = value < 1 ? 20 : (value > 500 ? 500 : value); } } public string OrderBy { get; set; } // 必须是唯一性有保证的字段,比如主键 public string Keyword { get; set; } // 可选筛选条件 // 给 OFFSET/FETCH 用的偏移量 public int Offset { get { return (PageIndex - 1) * PageSize; } } }属性里做夹取是血泪经验。页码越界最常见的情况不是用户手输,而是「删了最后一页的最后一条数据之后点刷新」,此时 PageIndex 还停在旧的最大页,OFFSET 算出来直接超过总行数,SQL 返回空集,界面一片空白,用户以为数据丢了。在模型层就把下限兜住,后面所有分支都省心。
PagedResult 是个泛型包装:
public class PagedResult<T> { public List<T> Items { get; set; } // 当前页数据 public int TotalCount { get; set; } // 满足条件的总行数 public int PageIndex { get; set; } public int PageSize { get; set; } public int PageCount { get { return TotalCount <= 0 ? 1 : (int)Math.Ceiling(TotalCount / (double)PageSize); } } }注意 PageCount 在 TotalCount 为 0 时返回 1 而不是 0。空结果集也应该显示「第 1 页 / 共 1 页」,否则控件里的页码算法会出除零或者负页数。这种边界值不写测试很难发现,上线后往往是被用户点出来的。
3.2 两条 SQL 分页路径与计数查询
SQL Server 2012 及以上,推荐 OFFSET/FETCH,语句短,语义直观:
-- 取第 N 页数据,@Offset = (页码-1) * 每页条数 SELECT OrderId, OrderNo, CustomerName, Amount, CreatedAt FROM dbo.Orders WHERE (@Keyword IS NULL OR OrderNo LIKE '%' + @Keyword + '%') ORDER BY OrderId DESC OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;@Keyword IS NULL这个写法是为了让「不带筛选」和「带筛选」共用一条语句,省得在 C# 里拼字符串。参数传 DBNull.Value 时条件恒真,走的是同一条执行计划,缓存命中率比动态拼 SQL 高。
SQL Server 2008 R2 及以下不认 OFFSET,退回 ROW_NUMBER 子查询:
-- 兼容 2005/2008,先编号再按行号区间截取 SELECT OrderId, OrderNo, CustomerName, Amount, CreatedAt FROM ( SELECT ROW_NUMBER() OVER (ORDER BY OrderId DESC) AS __rn, OrderId, OrderNo, CustomerName, Amount, CreatedAt FROM dbo.Orders WHERE (@Keyword IS NULL OR OrderNo LIKE '%' + @Keyword + '%') ) t WHERE t.__rn BETWEEN @StartRow AND @EndRow;@StartRow = Offset + 1,@EndRow = Offset + PageSize。BETWEEN 是闭区间,所以起点要加 1,这个 +1 漏掉的话每页之间会重复一行,翻页时数据看起来在「抖」。
两条路径都要配一条计数 SQL:
SELECT COUNT(1) FROM dbo.Orders WHERE (@Keyword IS NULL OR OrderNo LIKE '%' + @Keyword + '%');计数和取数最好在同一个连接上连着执行,必要时包一层事务(快照隔离级别更好),否则两次查询之间有人插入数据,会出现「总数 101 条、但翻到第 6 页是空的」这种诡异现象。对内部后台来说这个概率不高,但一旦发生,排查起来非常折磨人。
3.3 仓储层:参数化、DBNull 与结果映射
public class OrderRepository { private readonly string _connStr; public OrderRepository(string connStr) { _connStr = connStr; } public PagedResult<Order> GetPage(PageRequest req, bool useOffsetFetch = true) { string keyword = string.IsNullOrWhiteSpace(req.Keyword) ? null : req.Keyword.Trim(); var result = new PagedResult<Order> { PageIndex = req.PageIndex, PageSize = req.PageSize }; using (var conn = new SqlConnection(_connStr)) using (var cmd = conn.CreateCommand()) { conn.Open(); // 第一条:总数 cmd.CommandText = @"SELECT COUNT(1) FROM dbo.Orders WHERE (@Keyword IS NULL OR OrderNo LIKE '%' + @Keyword + '%')"; cmd.Parameters.Add("@Keyword", SqlDbType.NVarChar, 50).Value = (object)keyword ?? DBNull.Value; result.TotalCount = Convert.ToInt32(cmd.ExecuteScalar()); // 第二条:当前页数据 cmd.Parameters.Clear(); if (useOffsetFetch) { cmd.CommandText = @"SELECT OrderId, OrderNo, CustomerName, Amount, CreatedAt FROM dbo.Orders WHERE (@Keyword IS NULL OR OrderNo LIKE '%' + @Keyword + '%') ORDER BY OrderId DESC OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY"; cmd.Parameters.Add("@Offset", SqlDbType.Int).Value = req.Offset; cmd.Parameters.Add("@PageSize", SqlDbType.Int).Value = req.PageSize; } else { cmd.CommandText = @"SELECT OrderId, OrderNo, CustomerName, Amount, CreatedAt FROM ( SELECT ROW_NUMBER() OVER (ORDER BY OrderId DESC) AS __rn, OrderId, OrderNo, CustomerName, Amount, CreatedAt FROM dbo.Orders WHERE (@Keyword IS NULL OR OrderNo LIKE '%' + @Keyword + '%') ) t WHERE t.__rn BETWEEN @StartRow AND @EndRow"; cmd.Parameters.Add("@StartRow", SqlDbType.Int).Value = req.Offset + 1; cmd.Parameters.Add("@EndRow", SqlDbType.Int).Value = req.Offset + req.PageSize; } if (keyword != null) cmd.Parameters.Add("@Keyword", SqlDbType.NVarChar, 50).Value = keyword; var list = new List<Order>(req.PageSize); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new Order { OrderId = reader.GetInt32(0), OrderNo = reader.GetString(1), CustomerName = reader.IsDBNull(2) ? null : reader.GetString(2), Amount = reader.GetDecimal(3), CreatedAt = reader.GetDateTime(4) }); } } result.Items = list; } return result; } }参数说明几件事。@Keyword声明成 NVarChar(50),长度要和表里的 OrderNo 字段对齐,长度不一致时 SQL Server 可能放弃索引走全表扫描,几十万行的表上这个差别很明显。DBNull.Value必须显式转换,不能直接写null,否则 SqlParameter 会把 null 当成「不传参」处理,执行时报参数缺失。
结果映射用 reader 的序号取值,比按列名取快一点,但列顺序改了就出错;如果你会经常调整 SELECT 列表,改成reader["OrderNo"]更稳,代价是每行多做一次字典查找。这个项目里 SELECT 列表是固定的,所以选了序号。
3.4 控件内部:页码状态机与越界夹取
控件这边最核心的是 GoToPage 方法,所有按钮最终都汇到它:
private void GoToPage(int target, bool force = false) { if (_suspendEvent) return; // 回填总数期间屏蔽事件,防止无限循环 int maxPage = PageCount; // 依赖 RecordCount 和 PageSize if (target > maxPage) target = maxPage; if (target < 1) target = 1; if (target == _pageIndex && !force) return; // 页码没变就不发事件 _pageIndex = target; RefreshUi(); // 更新按钮可用状态和「第 x / y 页」文字 var handler = PageChanged; if (handler != null) handler(this, new PageChangedEventArgs(_pageIndex, _pageSize, PageChangeReason.ButtonClick)); } private void btnNext_Click(object sender, EventArgs e) { GoToPage(_pageIndex + 1); } private void btnPrev_Click(object sender, EventArgs e) { GoToPage(_pageIndex - 1); } private void btnFirst_Click(object sender, EventArgs e) { GoToPage(1); } private void btnLast_Click(object sender, EventArgs e) { GoToPage(PageCount); } private void btnGo_Click(object sender, EventArgs e) { int p; if (int.TryParse(txtGo.Text.Trim(), out p)) GoToPage(p); else { MessageBox.Show("请输入有效的页码"); txtGo.SelectAll(); txtGo.Focus(); } }force参数是给「切换每页条数」用的。用户把每页从 20 改成 50 时,当前页码可能还是 1,按上面的逻辑target == _pageIndex会直接 return,宿主收不到事件,表格就停在旧数据上。所以下拉框的处理里必须强制触发:
private void cboPageSize_SelectedIndexChanged(object sender, EventArgs e) { if (_suspendEvent) return; int size; if (int.TryParse(cboPageSize.Text, out size) && size > 0 && size != _pageSize) { _pageSize = size; GoToPage(1, true); // 改条数后回到第一页,并强制通知宿主 } }_suspendEvent这个标志位同样重要。宿主查完数据调 SetPageInfo 回填总数,SetPageInfo 内部会 RefreshUi,如果这里不小心又触发一次 PageChanged,就会变成「查库 → 回填 → 再查库」的死循环,界面上表现为表格一直闪、CPU 飙高。用一个布尔量把回填期间的事件屏蔽掉,是最省事的解法。
RefreshUi 里按当前页码禁用按钮:第一页时「首页」「上一页」灰掉,最后一页时「下一页」「末页」灰掉,总记录数 Label 显示「共 12345 条 第 3/618 页」。这些小细节不影响功能,但对使用体验影响很大,少了它用户永远不知道自己是不是已经在最后一页。
3.5 宿主窗体接线:事件回来之后做什么
public partial class MainForm : Form { private readonly OrderRepository _repo; private int _loading; // 0/1 标记,防止用户连点导致并发查询 public MainForm() { InitializeComponent(); string connStr = ConfigurationManager.ConnectionStrings["MainDb"].ConnectionString; _repo = new OrderRepository(connStr); pagerControl1.PageChanged += PagerControl1_PageChanged; } private void MainForm_Load(object sender, EventArgs e) { LoadPage(1); } private void PagerControl1_PageChanged(object sender, PageChangedEventArgs e) { LoadPage(e.PageIndex, e.PageSize); } private void LoadPage(int pageIndex, int pageSize = 20) { if (Interlocked.Exchange(ref _loading, 1) == 1) return; // 上一次还没查完,直接丢弃 try { var req = new PageRequest { PageIndex = pageIndex, PageSize = pageSize, Keyword = txtKeyword.Text.Trim() }; var data = _repo.GetPage(req); dgvOrders.DataSource = data.Items; // 只绑当前页 pagerControl1.SetPageInfo(data.TotalCount, data.PageIndex); } catch (Exception ex) { MessageBox.Show("查询失败:" + ex.Message); } finally { Interlocked.Exchange(ref _loading, 0); } } }接线只有三步:构造时订阅、Load 时查第一页、事件里重查。Interlocked.Exchange那个标记看起来有点多余,但用户按住「下一页」不放的时候,事件会连着抛好几次,每次都发一条 COUNT 加一条取数,数据库那边压力翻倍,界面还可能因为后到的结果覆盖先到的结果而页码错乱。加个并发保护,后到的请求直接丢掉,简单有效。
4. 本地跑通:从建表造数到控件绑上 DataGridView
代码看完,接下来是真的把它跑起来。这一章按顺序来:先建库建表,再造几万行测试数据,然后配连接串,最后拖控件接线。每一步都给出可以直接执行的脚本和代码。
4.1 建库建表与造 5 万行测试数据
-- 建库 IF DB_ID('PagerDemo') IS NULL CREATE DATABASE PagerDemo; GO USE PagerDemo; GO -- 建表,OrderNo 上建索引,因为筛选条件会用到 IF OBJECT_ID('dbo.Orders', 'U') IS NULL BEGIN CREATE TABLE dbo.Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(50) NOT NULL, CustomerName NVARCHAR(50) NULL, Amount DECIMAL(18,2) NOT NULL DEFAULT(0), CreatedAt DATETIME NOT NULL DEFAULT(GETDATE()) ); CREATE INDEX IX_Orders_OrderNo ON dbo.Orders(OrderNo); END GO -- 造 5 万行,用 CTE 递归比游标快得多 SET NOCOUNT ON; ;WITH N AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM N WHERE n < 50000 ) INSERT INTO dbo.Orders (OrderNo, CustomerName, Amount, CreatedAt) SELECT 'SO' + RIGHT('000000' + CAST(n AS VARCHAR(6)), 6), N'客户' + CAST(n AS NVARCHAR(10)), CAST(RAND(CHECKSUM(NEWID())) * 10000 AS DECIMAL(18,2)), DATEADD(MINUTE, -n, GETDATE()) FROM N OPTION (MAXRECURSION 0); GO建表脚本里把 OrderNo 的索引一起建了,因为分页查询的筛选条件会用到它。造数用 CTE 递归而不是 WHILE 循环,5 万行在普通开发机上几秒钟就能跑完;MAXRECURSION 0是必须的,默认递归上限 100 层,不加这个参数跑到 100 行就报错了。
4.2 连接字符串放哪、怎么读
App.config:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <connectionStrings> <add name="MainDb" connectionString="Server=.;Database=PagerDemo;Integrated Security=True;" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>读取用 ConfigurationManager,需要引用 System.Configuration 程序集:
using System.Configuration; string connStr = ConfigurationManager.ConnectionStrings["MainDb"].ConnectionString;注意:
connectionStrings节必须放在<configuration>下面、<startup>之前的位置,而且整个文件里只能出现一次,重复配置会让程序启动时直接抛 ConfigurationErrorsException,报错信息还不太好看懂。连接串里不要拼明文账号密码,本地开发用集成认证,上线再换成加密后的配置节。
4.3 把控件拖进窗体并接上仓储
步骤不复杂,但顺序别乱:
- 编译一次项目,让 PagerControl 出现在工具箱里。如果工具箱里看不到,右键工具箱选「选择项」,浏览到生成的 exe 或 dll,把 PagerControl 勾上。
- 从工具箱拖一个 PagerControl 到 MainForm 上,把 Dock 属性设成 Bottom,高度保持 36。
- 上面放一个 DataGridView,Dock 设成 Fill,AutoGenerateColumns 设为 True 便于先跑通,正式项目里建议手动定义列。
- 放一个 TextBox 做关键字输入、一个 Button 做查询,按钮的 Click 里调
LoadPage(1)。 - 在 MainForm 构造函数里订阅 PageChanged 事件,把 3.5 节的代码搬进去。
DataGridView 绑 List 时,列头会直接用属性名,界面不太好看。正式项目里一般把 AutoGenerateColumns 关掉,手动加 DataGridViewTextBoxColumn,DataPropertyName 对准属性名,HeaderText 写中文,宽度也顺手定好。这一步很琐碎但没有捷径,第一次做的时候花十分钟,后面复制窗体就省事了。
4.4 跑通验证清单
| 验证项 | 期望结果 | 不通过时先看哪里 |
|---|---|---|
| 启动后自动加载第一页 | 表格 20 行,页码显示「第 1/2500 页」 | MainForm_Load 有没有调 LoadPage(1) |
| 点「下一页」 | 数据变化,页码 +1 | PageChanged 有没有订阅上 |
| 在第一页点「上一页」 | 无反应,按钮是灰的 | RefreshUi 里的按钮禁用逻辑 |
| 跳转到 99999 页 | 自动跳到最后一页 | GoToPage 里的 maxPage 夹取 |
| 每页条数改成 50 | 回到第一页,表格 50 行 | 下拉框事件有没有传 force=true |
| 输入关键字查询 | 总数变小,页码重置为 1 | 按钮 Click 里是否调 LoadPage(1) |
| 点查询按钮连点 5 次 | 界面不卡死,数据正确 | Interlocked 并发标记是否生效 |
清单里的每一条都对应一个真实踩过的点。特别是最后一条,开发机上数据少感觉不出来,到客户那四五十万行一跑,连点两下就能把界面卡住十几秒。
5. 避坑与排查:分页上线后最容易翻车的五个坑
前面讲的都是「应该怎么写」,这一章讲「写完之后为什么不对」。这五个坑是我在同一类项目上反复见过的,现象、原因、解决方法都按实际排查顺序记下来。
5.1 坑一:翻到最后一页数据重复或丢失
现象:翻到第 3 页,第 2 页的最后几条又出现了;或者第 2 页少了中间几条,总数对得上但内容不全。
原因:排序字段不唯一。ORDER BY 用的字段如果有重复值(比如按 CreatedAt 排序,而同一秒里插入了多条),SQL Server 对相同值的行返回顺序是不确定的,每次查询可能给出不同的排列。OFFSET/FETCH 在两次查询之间「挪动」的时候就会错位。
解决:ORDER BY 后面必须补一个唯一性字段兜底,最省事的是加主键,写成ORDER BY CreatedAt DESC, OrderId DESC。不要用ORDER BY NEWID()做随机排序,那个东西每次执行结果都变,分页必然重复。
5.2 坑二:总记录数 COUNT 越翻越慢
现象:第一页打开还行,翻到后面几百页,每翻一次要等两三秒,SQL Profiler 里看到 COUNT 查询占了大头。
原因:COUNT 是全表统计,没有 LIMIT 的说法。表上如果没有合适的索引,或者 WHERE 里的 LIKE '%关键字%' 前置了通配符,就只能全表扫描。数据量到几十万行时,每次翻页都扫一遍,慢是必然的。
解决:一是给筛选字段建索引,能走前缀匹配就别用前后都带 % 的模糊查询;二是别每次翻页都重算总数,把 TotalCount 缓存在窗体里,只有筛选条件变化时才重新查;三是数据量再大的话考虑近似行数(sys.dm_db_partition_stats),后台系统对总页数精度要求没那么高。
5.3 坑三:设计器里拖不进控件,或者一编译就报错
现象:工具箱里能看到 PagerControl,拖到窗体上直接弹异常对话框;或者拖进去之后设计器打不开,整个 MainForm.Designer.cs 变成一堆红色错误。
原因:UserControl 的构造函数里有代码在跑。如果构造函数里读了配置文件、连了数据库,设计器加载控件时也会执行这些代码,一旦环境不对就抛异常。另外 Designer.cs 里手写的初始化代码如果引用了不存在的资源,设计器同样打不开。
解决:构造函数里只留 InitializeComponent(),任何外部依赖都移到 Load 事件或者单独的 Init 方法里。已经翻车的窗体,先把 MainForm.Designer.cs 里初始化 PagerControl 的那几行注释掉,能打开设计器之后再慢慢恢复,别硬着头皮在错误状态下改布局。
5.4 坑四:大数据量下界面假死、点什么都点不动
现象:点查询按钮后窗体变白,标题栏显示「无响应」,等十几秒才恢复;期间点关闭按钮都没反应。
原因:查询在 UI 线程上同步执行。ADO.NET 的 ExecuteReader 是阻塞调用,几十万行的 COUNT 加上取数,UI 线程被占住,Windows 消息循环停摆,窗体自然就假死。
解决:短期内最简单的办法是查询前把 Cursor 设成 WaitCursor,让用户知道在忙;正经做法是用 async/await 把查询扔到线程池,查询完再回到 UI 线程绑数据。注意 DataGridView 的 DataSource 赋值必须在 UI 线程上做,跨线程直接绑会抛 InvalidOperationException,正确姿势是this.Invoke或者 await 之后自然回到 UI 上下文。
5.5 坑五:改每页条数之后页码错乱
现象:从 20 条改成 50 条,页码显示「第 250/1000 页」,表格却是空的;或者改完之后点下一页,跳到了一个完全不相干的位置。
原因:改 PageSize 时没有重置 PageIndex。原来在第 250 页,改成 50 条一页之后总页数变成 1000,但 PageIndex 还是 250,OFFSET 算出来是 12450,而实际数据只有 5 万行、每页 50 条能取到 1000 页,看起来没越界,但用户期望的是回到第一页重新看。另一种情况是控件内部的 _pageSize 改了、宿主的 PageRequest.PageSize 没同步,两边算出来的 OFFSET 不一致。
解决:把「PageSize 变化」当成一个独立事件处理,强制回第一页,并且把新的 PageSize 一起传给宿主。3.4 节里 GoToPage 的 force 参数就是干这个的,别图省事复用普通翻页逻辑。
6. 二次开发:把控件抽成可换数据库的通用分页组件
跑通之后,多数人下一步是想把它用到别的表、别的库上,这时候原始代码里写死的 OrderRepository 就成了障碍。改造的方向很清楚:把「分页查询」抽象成一个接口,不同数据库给不同实现,控件完全不用动。
// 分页查询的统一契约,T 是实体类型 public interface IPageQuery<T> { PagedResult<T> Query(PageRequest request); } // SQL Server 实现 public class SqlServerPageQuery<T> : IPageQuery<T> { private readonly Func<PageRequest, PagedResult<T>> _inner; public SqlServerPageQuery(Func<PageRequest, PagedResult<T>> inner) { _inner = inner; } public PagedResult<T> Query(PageRequest request) { return _inner(request); } }实际落地时更常见的做法是不搞那么重的泛型,而是给 DbHelper 加一个DatabaseType枚举,在拼 SQL 的地方按枚举分支:
| 数据库 | 分页写法 | 注意点 |
|---|---|---|
| SQL Server 2012+ | OFFSET/FETCH | 必须带 ORDER BY,否则语法错误 |
| SQL Server 2008 | ROW_NUMBER 子查询 | BETWEEN 闭区间,起点要 +1 |
| MySQL | LIMIT @Offset, @PageSize | 参数位置固定,不能写反 |
| Oracle | ROW_NUMBER 或 OFFSET/FETCH(12c+) | 12c 之前只能用子查询 |
第二个改造点是把 COUNT 结果缓存起来。翻页时只要筛选条件没变,总数就不会变,完全没必要每页都查一次。做法是在 MainForm 里记一个_lastKeyword和_cachedTotal,关键字没变就复用,变了才重查。这个改动很小,但在几十万行的表上效果立竿见影,翻页延迟能降一大截。
第三个方向是异步化。把 LoadPage 改成 async 方法,仓储内部用ExecuteReaderAsync、ExecuteScalarAsync,窗体这边 await 之后绑数据。改完之后用户点查询,窗体还能拖动、还能关,体验上一个台阶。要注意控件里的_loading标记在异步场景下要换成按钮禁用,否则用户连点还是会重复发请求。
最后一个技巧:给控件加一个ShowPageSizeSelector属性,某些页面不需要让用户改每页条数(比如打印预览),把它设成 false 就把下拉框隐藏掉。这种小开关做两三个,控件就从一个「专用件」变成了「通用件」,后面新开页面直接拖,不用再复制粘贴改一遍。
我第一次把这套东西接进生产环境的时候,自信满满地没加排序兜底字段,结果上线第二天用户反馈「翻页有时候会看到重复订单」,排查了一整天才定位到 ORDER BY 不唯一。从那以后我每次写分页 SQL,ORDER BY后面不加主键就浑身难受,哪怕是临时脚本也要补上。希望这些踩过的坑能帮你少走一段弯路,代码本身不复杂,真正花时间的是这些边角上的细节。
本文还有配套的精品资源,点击获取