1. 先把“天数”的口径掰开,再动手写代码
做 C# 业务开发的人,十个里有八个写过“算两个日期差几天”的代码,真正踩过坑的可能只有两个。我见过太多这样的现场:代码上线三个月没问题,一到某个跨年、跨闰月或者带时分秒的边界数据进来,报表就多算一天、少算一天,排查半天最后发现是Days和TotalDays用混了。所以这篇文章我不打算一上来就甩代码,而是先把“天数”这个词拆开——因为 C# 里获取两个日期中间天数的写法本身并不复杂,复杂的是你到底要哪一种“天”。
先说清楚这个主题能解决什么问题:它覆盖的是日常业务里所有跟日期跨度相关的计算,包括但不限于租赁天数、请假时长、账期跨度、会员剩余天数、报表同比区间、排班周期。适合的读者是刚接触 .NET 一两年、写过DateTime但被时间部分坑过的同学,也适合做了几年业务、想把这块逻辑沉淀成一个稳定工具类的老手。下面我会从底层 Ticks 讲起,一路讲到工作日剔除、单元测试和批量计算的性能取舍。
1.1 三种口径:自然日跨度、绝对时长折算、工作日
在 C# 语境下,“两个日期中间的天数”至少对应三种完全不同的东西,而且它们的计算结果经常不相等:
| 口径 | 典型业务 | 计算方式 | 1 月 1 日 23:00 到 1 月 2 日 01:00 的结果 |
|---|---|---|---|
| 自然日跨度 | 请假天数、账期 | 只比较日期部分 | 1 天 |
| 绝对时长折算 | 计时租赁、SLA 时长 | 时间差除以 24 小时 | 约 0.083 天 |
| 工作日 / 计费日 | 排班、利息、工期 | 排除周末与节假日 | 视配置而定,可能是 1 也可能是 0 |
同样是上面那个时间点,自然日口径给出 1 天,绝对时长口径给出 2 小时。如果业务方说“我要算中间隔了几天”,你不追问一句“带不带时间、算不算首尾、周末算不算”,后面大概率要返工。我自己做过一个账单模块,前半年的需求是自然日口径,后来财务改口要用 24 小时制累计,整个账单口径重算了一遍,那次的教训就是:口径必须写进接口文档,而不是留在某个方法的注释里。
1.2 时区口径是第二个隐藏分叉
除了日期口径,还有时区口径。DateTime结构里只存两个东西:Ticks 和一个DateTimeKind标记(Unspecified / Local / Utc)。这个标记本身不携带偏移量,也不参与减法运算。也就是说,你用DateTime做减法时,减的是两个“墙上时间”的数字,跨时区比较会直接算错,而且不报错、不抛异常,静默给出错误结果。
DateTimeOffset则不同,它除了时间还存了相对 UTC 的偏移量,两个DateTimeOffset相减会先折算到同一时间轴再求差。所以判断标准很简单:如果你的日期来自用户输入、数据库里的datetime字段、或者固定的业务日期,用DateTime(或者 .NET 6 之后的DateOnly)就够;如果涉及多地区用户的即时时间、日志时间戳、调度触发时间,老老实实上DateTimeOffset。这个结论我在几套系统里反复验证过,选错了后面全是补丁。
2. TimeSpan 是地基:减法到底减了什么
2.1 DateTime 减法减的是 Ticks
C# 里最直观的写法就是两个DateTime直接相减,返回一个TimeSpan:
DateTime start = new DateTime(2024, 3, 1, 8, 30, 0); DateTime end = new DateTime(2024, 3, 10, 17, 45, 0); TimeSpan span = end - start; // 等价于 end.Subtract(start) Console.WriteLine(span.Days); // 9 Console.WriteLine(span.TotalDays); // 9.385416666666666这里end - start走的是DateTime.operator -,内部实现就是用两边的Ticks相减构造TimeSpan。DateTime的 1 个 Tick 是 100 纳秒,1 天 = 24 × 60 × 60 × 10^7 = 864,000,000,000 个 Tick。所以你写TotalDays的时候,.NET 实际上是拿Ticks / 864000000000.0做的浮点除法。理解了这一点,后面所有的精度问题都有了归因。
顺带说一句溢出:DateTime.MinValue到DateTime.MaxValue的跨度大约是 365 万天,远小于TimeSpan能表示的上限(约 1067 万天),所以两个合法DateTime相减不会溢出TimeSpan。这一点上不用额外做防御,但如果你拿TimeSpan去做AddDays反向构造日期,就要小心ArgumentOutOfRangeException了。
2.2 Days 是截断,不是四舍五入
TimeSpan.Days的文档描述是“获取当前 TimeSpan 结构所表示的时间间隔的天数部分”,关键在于“部分”两个字。它的计算等价于(int)(ticks / TicksPerDay),也就是向零取整。这意味着:
- 23 小时 59 分 →
Days是 0 - 24 小时 1 分 →
Days是 1 - -23 小时 59 分 →
Days是 0(不是 -1)
第三条是最容易出事的地方。如果你用end.Days - start.Days之类的方式算负数区间,或者把(int)span.TotalDays强制转换,负半天的场景会得到 0 而不是 -1,因为 C# 的(int)强转同样是向零截断。我在做会员到期提醒功能时就遇到过:到期前 12 小时,剩余天数显示 0 天,用户以为已经过期,客诉就来了。
正确的做法是先明确“你要的是哪一侧的边界”。想算“不足一天算一天”就用Math.Ceiling,想算“不满一天不算”就用Math.Floor,想四舍五入才用Math.Round,而且这三者对负数区间的行为各不相同,必须逐个确认。
2.3 TotalDays 的浮点陷阱,以及 Math.Round 的银行家舍入
TotalDays是double,直接把 Tick 差值换算成带小数的天数。它本身没问题,问题出在“换算完怎么取整”这一步。除了上面说的强转截断,还有一个更隐蔽的坑:Math.Round的默认舍入模式是“银行家舍入”(MidpointRounding.ToEven),也就是遇到正好 .5 的时候往偶数方向靠:
Console.WriteLine(Math.Round(0.5)); // 0,不是 1 Console.WriteLine(Math.Round(1.5)); // 2 Console.WriteLine(Math.Round(2.5)); // 2,不是 3 Console.WriteLine((int)0.5); // 0 Console.WriteLine((int)(-0.5)); // 0 Console.WriteLine(Math.Floor(-0.5)); // -1 Console.WriteLine(Math.Ceiling(-0.5)); // -0Math.Round(0.5)返回 0 这个结果,很多做了几年的人都没意识到,因为平时测的都是 1.4、1.6 这种不会正好落在中点的值。什么时候会正好落在 .5?最典型的就是“两个日期相差 12 小时”。当你用TotalDays处理跨半天的场景,并且数据恰好是整点录入,这个坑就会稳定复现。想要常见的“四舍五入”,必须显式写Math.Round(value, MidpointRounding.AwayFromZero)。
浮点本身还有一个精度问题:TotalDays是两个大整数做浮点除法,天数越大,末位的精度损失越明显。用它做“是否超过 N 天”的判断时,千万别写span.TotalDays == 30,要写成span.TotalDays >= 30,或者干脆退回整数比较span.Ticks >= TimeSpan.TicksPerDay * 30L。后者是我现在的默认写法,规避所有浮点比较的风险。
3. 把时间部分归一化:三种写法与选型建议
3.1 用 .Date 抹掉时间,最通用的老办法
自然日跨度最经典的一行代码是:
public static int DaysBetween(DateTime start, DateTime end) { return (end.Date - start.Date).Days; }DateTime.Date返回当天零点,Kind保持不变。这一步把时分秒抹平之后再做减法,结果就是纯粹的日历天数差,正负号也正确(因为两端都归零,差值必是整数天)。这行代码兼容所有 .NET 版本,是我在老项目里最常用的写法。
但它有两个要注意的地方。第一,.Date的开销并不为零,它会构造一个新的DateTime结构;在几百万次的循环里,这个开销是能测出来的。第二,.Date对DateTimeKind不做任何转换,如果你的end是Utc而start是Local,两者零点的含义根本不同,算出来的天数会莫名其妙地差一天。遇到这种混合来源的日期,正确姿势是先统一Kind再比较,而不是直接上.Date。
注意:
DateTime的Kind不会在减法里自动生效,也不会抛异常。混用Local和Utc做日期计算,属于“静默错误”,线上很难发现。
3.2 用 DateOnly 从类型层面根治
.NET 6 引入了DateOnly,这个东西就是为“只有日期没有时间”的场景设计的。它的DayNumber属性表示从公元 1 年 1 月 1 日起的天数序号,于是两个日期的跨度可以退化成一次整数减法:
DateOnly start = new DateOnly(2024, 2, 1); DateOnly end = new DateOnly(2024, 3, 1); int days = end.DayNumber - start.DayNumber; // 29,2024 是闰年这是我目前最推荐的写法,理由有三条。第一,类型上就不给你塞时间的机会,从源头消灭“时间部分污染”;第二,DayNumber相减是纯整数运算,没有浮点、没有截断歧义;第三,语义清楚,看签名就知道业务只要日期。代价是它要求 .NET 6 及以上,老项目用不了;另外它和DateTime互转时要显式写ToDateTime()/FromDateTime(),跨库序列化(比如某些 ORM 映射)也需要确认版本支持。
如果你还在 .NET Framework 上跑,也没有升级计划,那就老老实实用.Date方案,或者自己在工具类里封装一个“把时间部分截断”的方法,别在每处调用点手写new DateTime(y, m, d),那样迟早写错一个参数。
3.3 跨时区场景必须上 DateTimeOffset
前面提过,涉及多地区时间的时候要用DateTimeOffset。它的减法行为是:先把两个时刻折算到 UTC,再求差:
DateTimeOffset a = new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.FromHours(8)); DateTimeOffset b = new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.FromHours(-5)); TimeSpan diff = b - a; // 相差 13 小时,而不是 0如果你这里用DateTime,两个都写 0 点,减出来是 0,业务上完全错了,但代码不报错。判断该用哪个的经验法则是:这个时间点是不是“绝对时刻”。打卡时间、订单创建时间、消息发送时间都是绝对时刻,用DateTimeOffset;生日、账期截止日、排班日期是“日历概念”,用DateOnly或纯日期DateTime。混着用就等着出跨时区事故。
另外还有一种很常见的中间态:数据库里统一存 UTC 的DateTime,展示层再转本地时区。这种架构下算自然日跨度,必须先转成本地时间再取.Date,否则跨零点前后的数据会整体偏一天。这个坑我在报表里踩过,数据量大的时候肉眼根本看不出来,只有对账时才会发现总量差了。
4. 沉淀成工具类:扩展方法、区间口径与工作日
4.1 扩展方法的写法与命名
零散的(end - start).Days不可怕,可怕的是同一个项目里有七八种写法。我的做法是统一收口到一个静态工具类,用扩展方法暴露出去,让调用方一眼看懂口径:
public static class DateRangeExtensions { /// <summary>自然日跨度,忽略时分秒,结果可为负</summary> public static int DaysBetween(this DateOnly start, DateOnly end) => end.DayNumber - start.DayNumber; /// <summary>自然日跨度(DateTime 版,截断时间部分)</summary> public static int DaysBetween(this DateTime start, DateTime end) => (end.Date - start.Date).Days; /// <summary>自然日跨度,含首尾两天</summary> public static int DaysInclusive(this DateOnly start, DateOnly end) => end.DayNumber - start.DayNumber + 1; /// <summary>绝对时长折算天数,向上取整,适合计费场景</summary> public static int BillingDays(this DateTime start, DateTime end) { if (end < start) throw new ArgumentException("结束时间不能早于开始时间"); var span = end - start; return (int)Math.Ceiling(span.Ticks / (double)TimeSpan.TicksPerDay); } }命名上我坚持两点:方法名里体现口径(DaysBetween/DaysInclusive/BillingDays),参数顺序保持“开始在前、结束在后”。别小看后者,(start - end).Days写反了不会报错,只会得到负数,然后在某个if (days > 30)的分支里安静地走错。至于要不要在方法里对负数抛异常,取决于业务:跨度本身允许反向的场景(比如算趋势)就别抛,只做纯计算的场景抛异常能更早暴露上游传参错误。
4.2 含首尾还是不含首尾,这是个业务问题
技术上的除法好写,业务上的“1 号到 3 号算几天”才是真问题。酒店住 1 号到 3 号是两晚,请假 1 号到 3 号是三天,同一个数据区间在不同业务下差 1。所以工具类里必须同时提供“间隔”和“含首尾”两个入口,并且让调用方显式选择,别在方法内部偷偷 +1。
这里有个细节值得说:DateOnly.DayNumber相减得到的是“中间隔了多少天”,也就是 Java 里ChronoUnit.DAYS.between的语义。如果业务方说“含头不含尾”,那刚好就是这个结果;如果“含头含尾”,再 +1。我在接口设计上会把这一点写进 XML 注释,并且在单元测试里用 1 号到 1 号这种退化用例固定住行为:同一天,间隔是 0,含首尾是 1。这个用例写下来之后,后续接手的人基本不会再问。
4.3 剔除周末和节假日:别用循环硬怼
工作日计算是另一个高频需求,简单版本是“区间内排除周六周日”,复杂版本还要排除法定节假日和调休。先给一个性能可控的实现思路:
public static int WorkDaysBetween(DateOnly start, DateOnly end, ISet<DateOnly> holidays) { if (end < start) return 0; int total = end.DayNumber - start.DayNumber + 1; int fullWeeks = total / 7; int remain = total % 7; int workDays = fullWeeks * 5; for (int i = 0; i < remain; i++) { var d = start.AddDays(fullWeeks * 7 + i); if (d.DayOfWeek != DayOfWeek.Saturday && d.DayOfWeek != DayOfWeek.Sunday) workDays++; } foreach (var h in holidays) if (h >= start && h <= end && h.DayOfWeek != DayOfWeek.Saturday && h.DayOfWeek != DayOfWeek.Sunday) workDays--; return workDays; }核心思路是“整周 × 5 + 余数逐个判断”,避免逐天遍历整个区间。一个三年的区间如果逐天循环是上千次迭代,整周算法只需要最多 6 次循环加一次节假日集合遍历,量级完全不同。节假日集合用HashSet<DateOnly>,判断是否落在区间里用DateOnly比较运算符,比转成字符串或者DateTime再比要干净。
这里要提醒一个容易被忽略的点:节假日扣减时,如果某个节假日正好落在周六周日,不能重复扣,否则工作日会被多减一天。上面的代码用DayOfWeek判断做了防护。另外,调休(周末上班)这种场景,如果只用一个“节假日集合”是表达不了的,需要再加一个“补班日集合”,判定逻辑变成“先判补班日算工作日,再判节假日扣除”。这类需求在国内业务里很常见,设计数据结构的时候最好一步到位。
5. 踩坑实录与排查清单
5.1 常见错误速查表
下面这张表是我这些年实际遇到或帮人排查过的问题,按出现频率排序:
| 现象 | 根因 | 修法 |
|---|---|---|
| 跨零点数据多算/少算一天 | 没截断时间部分,直接(end - start).Days | 两端先.Date或用DateOnly |
| 相差不到一天显示为 0 | Days向零截断 | 换Math.Ceiling(TotalDays) |
| 正数向上取整正常,负数异常 | (int)TotalDays向零取整 | 负数场景显式Math.Floor |
| 12 小时场景四舍五入结果不对 | Math.Round默认银行家舍入 | 指定MidpointRounding.AwayFromZero |
| 跨时区用户算出的天数不一致 | DateTime混用 Kind | 统一Kind或改DateTimeOffset |
| 闰年 2 月算少一天 | 手写月份天数表忘了闰年 | 交给DateTime.DaysInMonth或DateOnly |
| 大数据量报表计算慢 | 循环里反复Parse和构造DateTime | 提前解析成DateOnly,用整数运算 |
| 工作日计算结果偏大 | 节假日与周末重复扣减 | 扣减时排除周末 |
这张表建议直接贴到团队 wiki 里。我见过太多人对着“差一天”的问题查半天时区,最后发现只是Days截断。把现象和根因一一对应上,排查能省掉一半时间。
5.2 单元测试怎么写才算覆盖到位
日期计算的测试不在于数量,而在于把边界钉死。我现在的习惯是每个日期工具方法至少覆盖这几类用例:同一天(跨度 0)、相邻天(跨度 1)、跨月、跨年、闰年 2 月 29 日前后、带时分秒的近零点、反向区间(结束早于开始)、以及一个跨时区的用例(如果方法支持DateTimeOffset)。用 xUnit 的Theory把这些写成数据驱动测试,维护成本极低:
[Theory] [InlineData(2024, 2, 28, 2024, 3, 1, 2)] // 闰年 [InlineData(2023, 2, 28, 2023, 3, 1, 1)] // 平年 [InlineData(2024, 12, 31, 2025, 1, 2, 2)] // 跨年 [InlineData(2024, 5, 1, 2024, 5, 1, 0)] // 同一天 public void DaysBetween_ShouldMatch(int y1, int m1, int d1, int y2, int m2, int d2, int expected) { var start = new DateOnly(y1, m1, d1); var end = new DateOnly(y2, m2, d2); Assert.Equal(expected, end.DayNumber - start.DayNumber); }带时间的用例不能只测 00:00:00,一定要加一个23:59:59和一个00:00:01,因为截断相关的 bug 只在临界点附近才暴露。我还会专门写一个“深夜到凌晨”的用例,用2024-01-01 23:30到2024-01-02 00:30,断言自然日跨度是 1 天而绝对时长是 1 小时。这个用例写下来,等于把前面讨论的口径问题固化成了可执行的契约。
5.3 批量计算场景的性能取舍
单次调用的时候,DateOnly和DateTime.Date的差异可以忽略。但报表导出、大批量对账这类场景,同一个方法可能被调用几十万次,量级差异就出来了。我的实测经验是:用DateOnly.DayNumber的整数减法明显快于构造两次DateTime再相减,因为后者要多做两次结构构造和一次TimeSpan创建。数值上不夸张,但在密集循环里是实打实的收益。
还有两个容易忽略的开销。一是字符串解析,DateTime.Parse比DateTime.ParseExact灵活但慢,比DateOnly.ParseExact更慢,批量场景里应该把解析提到循环外面,或者干脆在数据入口一次性转好。二是TimeZoneInfo.ConvertTime,它内部要查时区规则,反复调用代价不小,能缓存TimeZoneInfo实例的就缓存。这些优化单独看不值一提,叠加在百万级循环里就是分钟级的差别。
注意:性能优化之前先测量。我见过有人在只有几百条数据的循环里大动干戈重构日期计算,收益不到 1 毫秒,反而把可读性搞没了。先用
Stopwatch或者基准测试跑一遍,确认热点真的在这里再动手。
6. 我个人的几条实操体会
写日期逻辑这些年,最深的感受是:代码量从来不是问题,口径才是。end.DayNumber - start.DayNumber这一行谁都会写,难的是在需求评审的时候问出“带不带时间、算不算首尾、周末怎么算、时区怎么定”。我现在接任何跟日期跨度有关的需求,都会在接口注释里把这四条写清楚,宁可多花五分钟,也不愿意上线后再返工。
第二个体会是类型选择比算法更重要。DateOnly出现之前,我们用DateTime算日期跨度,全靠开发者自觉调用.Date,团队里只要有一个人忘了,bug 就埋进去了。换成DateOnly之后,这类问题从“需要人记住的规范”变成了“编译器帮你拦住的错误”。能用类型表达的约束,就别指望文档和代码评审。
最后分享一个小习惯:凡是跟日期计算相关的工具方法,我都会在同一个文件里放一组Theory测试数据,覆盖闰年、跨年、同一天和近零点这四类。这个习惯让我在两次框架升级里省掉了大量回归测试时间,因为跑一遍测试就知道行为有没有变。日期这种“看起来简单、边界极多”的东西,值得把测试写得比实现还认真。