1. 为什么 .NET 开发者需要一份年度复盘
每年到了这个时候,技术社区里总会出现两种极端的声音。一种是把 .NET 捧上天,说它是宇宙第一语言、性能吊打一切;另一种是唱衰,说它生态封闭、跨平台是伪命题、迟早被别的技术栈取代。这两种声音听多了,人容易迷失。我自己写了十多年 C#,从早期的 Web Forms 一路做到现在的云原生微服务,踩过的坑、换过的技术选型、推翻重来的架构设计,加起来能写好几本笔记。所以当有人问我“2025 年 .NET 到底值不值得投入”时,我从来不给一个简单的“值”或“不值”,而是会反问一句:你打算用它解决什么问题?
这份年度总结不是官方路线图的中文翻译,也不是社区新闻的流水账。它更像是一个一线开发者坐在你对面,把自己这一年真正用过、调过、骂过又真香过的技术点,一条一条摊开来讲。我会聊到运行时层面的性能变化、语言特性的实际落地感受、跨平台部署时那些文档里不会写的坑,以及团队协作中如何用工具链把效率拉满。如果你是一个刚入行的 .NET 新手,这篇文章能帮你少走半年弯路;如果你是一个带团队的技术负责人,里面关于架构取舍和工具选型的部分应该能给你一些参考。
核心关键词我会在行文中自然带出,比如.NET 10、AOT 编译、云原生、Minimal API、性能调优、跨平台部署。这些不是拿来堆砌的标签,而是我这一年实际工作中反复打交道的对象。下面进入正题,我会从整体设计思路开始拆,然后逐层深入到代码和配置层面。
2. 2025 年 .NET 技术全景与核心变化拆解
2.1 运行时与语言特性的实际体感
2025 年的 .NET 版本迭代节奏依然稳定,但真正让我在项目里感受到变化的,不是版本号本身,而是几个具体的能力落地。首先是AOT 编译的成熟度明显上了一个台阶。前两年用 Native AOT 发布一个控制台工具,光是反射相关的报错就能折腾一整天,很多第三方库直接不兼容。今年我再试同样的场景,发现主流 JSON 序列化库和依赖注入容器基本都跟上了,发布出来的单文件体积从原来的 70 多 MB 降到了 12 MB 左右,启动时间从 300 毫秒压到了 40 毫秒以内。这个变化对于做 CLI 工具和边缘计算场景的人来说,是实打实的生产力提升。
语言层面,主构造函数和集合表达式这两个特性我一开始觉得是语法糖,用多了才发现它们对代码可读性的影响比想象中大。举个例子,以前定义一个简单的数据传输对象,要写属性、写构造函数、写赋值逻辑,十几行代码就为了传三个字段。现在一行主构造函数搞定,配合集合表达式初始化列表,代码量直接砍半。但这里有个坑:主构造函数捕获的参数在结构体里会带来额外的字段开销,如果你在高频调用的热路径上用结构体加主构造函数,性能反而可能下降。我实测过一个循环一亿次的场景,结构体主构造函数版本比显式字段版本慢了大约 8%。所以语法糖虽好,得看用在哪里。
另一个值得说的是Minimal API的生态完善。2025 年再回头看,Minimal API 已经不再是“玩具项目专用”了。我们团队有一个日请求量千万级的网关服务,就是用 Minimal API 重写的,配合端点过滤器做认证和日志,代码量比原来的 Controller 模式少了将近 40%,而且因为减少了中间层的反射调用,P99 延迟下降了 15% 左右。当然,这不是说 Controller 模式就该淘汰,大型团队协作、需要复杂模型绑定和过滤器管道的场景,Controller 依然有它的优势。选型的关键在于你团队的规模和接口的复杂度。
2.2 云原生与跨平台部署的现状
云原生这个词被说了好多年,但在 .NET 生态里,2025 年真正让我觉得“落地了”的标志,是官方容器镜像的优化和Kubernetes探针的集成度。以前把一个 .NET 服务塞进容器,镜像动辄 200 MB 起步,现在用 chiseled 镜像配合 AOT 发布,生产镜像可以控制在 50 MB 以内。别小看这个数字,在边缘节点批量拉取镜像的时候,体积小一半意味着部署速度翻倍,故障恢复的时间窗口也大大缩短。
跨平台方面,Linux 上的性能表现已经和 Windows 非常接近了,某些场景下甚至更好。我做过一个对比测试,同样的数据处理服务,在 Linux 容器里跑比在 Windows 上跑吞吐量高了约 12%,内存占用低了 8%。这个差异主要来自 GC 在不同平台上的策略调优。但跨平台不是没有代价的,最大的坑在于文件路径处理和时区数据库。Windows 上用反斜杠的路径,到了 Linux 上直接报错;时区 ID 在两个平台上的命名也不一样,China Standard Time在 Linux 上得写成Asia/Shanghai。这些问题在开发阶段很容易被忽略,因为开发机往往是 Windows,一上容器就炸。我的经验是,项目一开始就强制用Path.Combine处理路径,时区统一用 IANA 格式,能省掉后面大量的排查时间。
2.3 工具链与团队协作的效率提升
工具链这块,2025 年让我印象最深的是dotnet watch的热重载稳定性大幅提升。以前改一行代码,热重载要么不生效,要么生效了但状态丢失,最后还是得手动重启。今年在多个项目里实测,热重载的成功率目测在 90% 以上,尤其是改 Razor 页面和 Minimal API 端点的时候,几乎秒级生效。这对于前端和后端联调的场景帮助巨大,以前改一个字段名要等十几秒重启,现在保存即生效,一天下来能省出不少时间。
团队协作方面,中央包管理(Central Package Management)终于被我们全面用起来了。以前十几个项目各自管理 NuGet 包版本,经常出现 A 项目用 1.0 版本、B 项目用 2.0 版本,合并的时候冲突不断。现在用一个Directory.Packages.props文件统一管版本,所有项目只写包名不写版本号,升级的时候改一处就行。这个改变看起来小,但在多项目解决方案里,它把依赖冲突的概率降低了至少一半。配合dotnet list package --outdated命令定期检查过期包,整个团队的依赖管理清爽了很多。
3. 核心实操:从零搭建一个可落地的 .NET 服务
3.1 项目结构与依赖管理配置
光说思路不够,下面我拿一个实际的服务来演示。假设我们要做一个商品查询的 API,要求支持高并发、能容器化部署、启动速度快。第一步是项目结构设计。我的习惯是至少分三层:Api层负责端点定义和中间件,Core层放业务逻辑和领域模型,Infrastructure层处理数据库和外部服务调用。这个分层不是教条,而是为了在单元测试的时候能方便地替换依赖。
创建项目用命令行最直接:
dotnet new webapi -n ProductApi --use-minimal-apis dotnet new classlib -n ProductApi.Core dotnet new classlib -n ProductApi.Infrastructure dotnet add ProductApi reference ProductApi.Core dotnet add ProductApi.Infrastructure reference ProductApi.Core dotnet add ProductApi reference ProductApi.Infrastructure依赖管理用中央包管理,在解决方案根目录建一个Directory.Packages.props:
<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup> <PackageVersion Include="Dapper" Version="2.1.35" /> <PackageVersion Include="Npgsql" Version="8.0.5" /> <PackageVersion Include="Serilog.AspNetCore" Version="8.0.3" /> </ItemGroup> </Project>这样各个项目文件里只写<PackageReference Include="Dapper" />,不写版本号。升级的时候只改这一个文件,所有项目同步更新。这里有个细节:ManagePackageVersionsCentrally设为 true 之后,如果某个项目需要覆盖版本,可以用VersionOverride属性,但我的建议是尽量不要覆盖,保持统一。
3.2 Minimal API 端点设计与性能考量
端点设计我倾向于按业务能力分组,而不是按 HTTP 方法分组。比如商品相关的端点放在一个静态类里:
public static class ProductEndpoints { public static void MapProductEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/products") .WithTags("Products") .RequireAuthorization(); group.MapGet("/{id:int}", async (int id, IProductService service) => { var product = await service.GetByIdAsync(id); return product is not null ? Results.Ok(product) : Results.NotFound(); }) .WithName("GetProductById") .CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(5))); group.MapGet("/search", async (string keyword, int page, IProductService service) => { var results = await service.SearchAsync(keyword, page, 20); return Results.Ok(results); }) .WithName("SearchProducts"); } }这里有几个性能相关的点值得展开。第一,MapGroup配合RequireAuthorization可以批量给一组端点加认证,比每个端点单独加要清晰得多。第二,CacheOutput是 .NET 8 引入的输出缓存,配合Expire设置过期时间,对于读多写少的接口能显著降低数据库压力。我实测过一个商品详情接口,加了输出缓存之后,QPS 从 2000 提升到了 8000 以上,因为大部分请求直接命中缓存,根本没走到数据库。
但输出缓存有个坑:它默认按 URL 缓存,如果接口依赖用户身份返回不同数据,必须配置VaryByHeader或者干脆不用。我见过一个项目因为没注意这点,把 A 用户的购物车数据缓存后返回给了 B 用户,差点出大事。所以用缓存之前,先问自己:这个接口的响应是否对所有调用者都一样?如果答案是否定的,要么加区分维度,要么别用。
3.3 数据访问层的选型与调优
数据访问这块,EF Core 和 Dapper 的争论从来没停过。我的观点很明确:写操作和复杂查询用 Dapper,简单的增删改查用 EF Core。EF Core 的优势在于变更追踪和迁移管理,但它的性能开销在复杂查询场景下比较明显。我做过一个对比,同样的多表关联查询,Dapper 手写 SQL 比 EF Core 生成的 SQL 快了将近 3 倍,因为 EF Core 生成的 SQL 往往包含不必要的 JOIN 和子查询。
Dapper 的配置很简单,在Program.cs里注册:
builder.Services.AddSingleton<IDbConnectionFactory>(sp => { var connectionString = builder.Configuration.GetConnectionString("Default"); return new NpgsqlConnectionFactory(connectionString); });然后写一个仓储类:
public class ProductRepository : IProductRepository { private readonly IDbConnectionFactory _factory; public ProductRepository(IDbConnectionFactory factory) => _factory = factory; public async Task<Product?> GetByIdAsync(int id) { using var connection = await _factory.CreateAsync(); const string sql = "SELECT id, name, price, stock FROM products WHERE id = @Id"; return await connection.QuerySingleOrDefaultAsync<Product>(sql, new { Id = id }); } }这里有个经验:连接不要每次查询都新建,用连接池。Npgsql 默认开启连接池,但要注意连接字符串里的Maximum Pool Size参数,默认是 100,高并发场景下可能需要调大。我遇到过一次线上故障,就是因为连接池满了,请求全部排队等待,最后超时。后来把最大连接数调到 200,同时优化了慢查询,问题才解决。所以连接池不是设了就行,得根据实际并发量来调。
4. 性能调优与问题排查实录
4.1 常见性能瓶颈的定位方法
性能问题最怕瞎猜。我的习惯是先用工具定位,再动手优化。.NET 生态里,dotnet-counters和dotnet-trace是两个必备的命令行工具。dotnet-counters可以实时看 CPU、内存、GC 次数这些指标,dotnet-trace能抓取详细的调用栈,找出热点方法。
举个例子,有一次一个服务的 CPU 一直跑满,但 QPS 并不高。我用dotnet-counters monitor看了一眼,发现 GC 的 Gen2 回收次数异常频繁。Gen2 回收频繁通常意味着有大量对象被提升到了第二代,这往往是因为对象存活时间过长或者大对象堆分配过多。接着用dotnet-trace collect抓了一段,分析后发现是一个缓存字典没有设置过期策略,数据越积越多,每次 GC 都要扫描整个大对象堆。加上过期清理逻辑之后,CPU 占用直接降了一半。
排查性能问题的顺序我总结为:先看指标,再抓调用栈,最后看代码。不要一上来就翻代码,那样效率太低。指标告诉你哪里有问题,调用栈告诉你哪个方法有问题,代码才告诉你为什么有问题。
4.2 内存泄漏与 GC 压力的实战处理
内存泄漏在 .NET 里不算常见,因为有大名鼎鼎的 GC 兜底。但有两种情况 GC 也救不了:一是事件订阅没有取消订阅,导致对象一直被引用;二是静态集合不断添加元素,从不移除。我踩过第二个坑。一个服务里用静态ConcurrentDictionary缓存用户会话,想着反正内存够大,结果跑了三天之后内存爆了。原因是会话过期后没有从字典里移除,字典越来越大,最后 OOM。
解决办法是给缓存加一个定时清理的后台任务:
public class SessionCleanupService : BackgroundService { private readonly ConcurrentDictionary<string, Session> _sessions; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { var expired = _sessions.Where(kv => kv.Value.ExpiresAt < DateTime.UtcNow) .Select(kv => kv.Key) .ToList(); foreach (var key in expired) { _sessions.TryRemove(key, out _); } await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); } } }这里用BackgroundService是标准做法,注册的时候用AddHostedService。注意清理逻辑里先ToList再遍历删除,因为直接遍历ConcurrentDictionary的时候删除元素会导致枚举器失效。这个细节文档里不会强调,但实际写的时候不注意就会报错。
4.3 容器化部署中的典型故障排查
容器化部署的故障排查和传统部署不太一样,因为你看不到完整的操作系统环境。我整理了一个常见问题速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 容器启动后立即退出 | 缺少运行时依赖或入口点配置错误 | 查看容器日志,检查 Dockerfile 的 ENTRYPOINT |
| 服务间调用超时 | 网络策略限制或 DNS 解析失败 | 在容器内执行nslookup和curl测试连通性 |
| 内存占用持续增长 | 未设置容器内存限制,GC 策略未适配 | 设置DOTNET_GCHeapHardLimit环境变量 |
| 时区显示错误 | 容器默认 UTC 时区 | 挂载时区文件或设置TZ环境变量 |
| 文件写入失败 | 容器文件系统只读或权限不足 | 检查挂载卷权限,使用非 root 用户运行 |
其中内存限制这个问题特别值得说。.NET 的 GC 默认会根据物理机内存来调整策略,但在容器里,它看到的可能是宿主机的内存,而不是容器的限制。这会导致 GC 认为内存很充裕,不及时回收,最后被容器运行时 OOM Kill。解决办法是设置DOTNET_GCHeapHardLimit环境变量,值设为容器内存限制的 75% 左右。比如容器限制 1 GB,就设成0x30000000(十六进制表示 768 MB)。这个参数我调过很多次,设了之后容器被 Kill 的概率大幅下降。
5. 工具链与生态的深度使用心得
5.1 调试与诊断工具的组合拳
除了前面提到的dotnet-counters和dotnet-trace,还有几个工具我几乎每天都在用。dotnet-dump用来抓取和分析内存转储文件,当服务崩溃但日志没留下有用信息时,它就是最后的救命稻草。用法是先dotnet-dump collect -p <pid>抓取,然后用dotnet-dump analyze进入交互式分析,输入clrstack看线程栈,dumpheap -stat看对象统计。我靠这个工具定位过一个死锁问题,两个线程互相等待对方的锁,日志里完全看不出来,dump 一抓就清楚了。
dotnet-gcdump是另一个轻量级工具,专门用来分析 GC 堆。它比完整的 dump 小很多,对生产环境影响也小。用法是dotnet-gcdump collect -p <pid>,然后用 Visual Studio 或者dotnet-gcdump report查看。我通常用它来对比不同时间点的堆快照,看看哪些对象在持续增长。如果某个类型的对象数量一直涨,那基本就是泄漏点。
这些工具的组合使用逻辑是:先用dotnet-counters发现异常指标,再用dotnet-trace定位热点方法,如果怀疑内存问题就用dotnet-gcdump看堆增长,最后用dotnet-dump做深度分析。这个流程我走了很多遍,基本能覆盖 90% 以上的线上问题。
5.2 单元测试与集成测试的平衡
测试这块,我的原则是:核心业务逻辑必须单元测试,跨服务调用必须集成测试,UI 测试看情况。单元测试用 xUnit 配合 Moq 或者 NSubstitute,集成测试用WebApplicationFactory起一个内存中的服务实例。这里有个经验:集成测试不要连真实的数据库,用 Testcontainers 起一个临时容器,测试完自动销毁。这样既保证了测试的真实性,又不会污染开发数据库。
public class ProductApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public ProductApiTests(WebApplicationFactory<Program> factory) { _client = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { services.RemoveAll<IDbConnectionFactory>(); services.AddSingleton<IDbConnectionFactory>(new TestConnectionFactory()); }); }).CreateClient(); } [Fact] public async Task GetProduct_ReturnsOk_WhenProductExists() { var response = await _client.GetAsync("/api/products/1"); response.EnsureSuccessStatusCode(); } }注意WebApplicationFactory<Program>里的Program类,在 Minimal API 项目里需要加一个public partial class Program { }才能被测试项目引用。这个细节很多人不知道,导致测试项目编译不过。加上之后,集成测试就能像调用普通类一样调用整个应用管道。
5.3 持续集成与部署流水线的搭建
CI/CD 这块,我用得最多的是 GitHub Actions 和 GitLab CI。核心步骤无非是还原依赖、编译、跑测试、打包镜像、推送到镜像仓库、部署到目标环境。但有几个细节决定了流水线的稳定性。第一,缓存 NuGet 包。每次构建都重新下载依赖太浪费时间,用actions/cache把~/.nuget/packages缓存起来,构建时间能缩短一半以上。第二,分阶段构建 Docker 镜像。用多阶段 Dockerfile,构建阶段用完整的 SDK 镜像,运行阶段用 runtime 镜像,最终镜像体积能小很多。
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY *.sln . COPY ProductApi/*.csproj ProductApi/ COPY ProductApi.Core/*.csproj ProductApi.Core/ COPY ProductApi.Infrastructure/*.csproj ProductApi.Infrastructure/ RUN dotnet restore COPY . . RUN dotnet publish ProductApi/ProductApi.csproj -c Release -o /app/publish /p:PublishAot=true FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled AS runtime WORKDIR /app COPY --from=build /app/publish . ENTRYPOINT ["./ProductApi"]这个 Dockerfile 里用了 AOT 发布和 chiseled 镜像,最终镜像大概 40 MB 左右。注意 AOT 发布需要目标平台和构建平台一致,如果你在 Windows 上构建 Linux 镜像,得用-r linux-x64指定运行时标识符。另外 chiseled 镜像没有 shell,调试的时候不太方便,建议生产用 chiseled,开发环境用普通的 runtime 镜像。
6. 团队协作与代码质量的长期维护
6.1 代码规范与静态分析工具
团队大了之后,代码风格不统一是必然的。我的做法是在解决方案根目录放一个.editorconfig文件,把命名规范、缩进、换行这些规则全部定义好。Visual Studio 和 VS Code 都会自动读取这个文件,写代码的时候实时提示。配合EnforceCodeStyleInBuild属性设为 true,编译的时候如果代码不符合规范会直接报错,强制所有人遵守。
[*.cs] dotnet_diagnostic.IDE0005.severity = error dotnet_diagnostic.CA1062.severity = error csharp_style_namespace_declarations = file_scoped:error dotnet_sort_system_directives_first = true上面这几条规则里,IDE0005是要求删除未使用的 using,CA1062是要求对公共方法的参数做空检查,file_scoped是要求用文件作用域的命名空间声明。这些规则看起来琐碎,但坚持下来,代码的整洁度会有明显提升。我见过一个项目因为没开这些规则,代码里到处都是未使用的 using 和冗余的空检查,读起来非常累。
6.2 代码审查中的常见问题与改进建议
代码审查不是找茬,而是知识共享和风险控制。我在审查中最常发现的问题有三类:第一类是异步方法没有加 ConfigureAwait(false),这在库代码里会导致死锁风险;第二类是异常被吞掉,catch 了之后只打日志不处理,调用方完全不知道出错了;第三类是资源没有释放,比如HttpClient每次请求都 new 一个,导致端口耗尽。
针对这些问题,我的建议是:库代码里所有 await 都加ConfigureAwait(false),应用层代码可以不加;异常处理要么恢复、要么包装后抛出、要么记录后让上层决定,不要静默吞掉;HttpClient用IHttpClientFactory管理,不要手动 new。这些规则说起来简单,但真正在团队里推行下去需要反复强调和示例。
6.3 技术债务的管理与偿还策略
技术债务不是洪水猛兽,适度的债务可以加快交付速度。关键是记录在案、定期偿还。我的做法是在项目看板里开一个“技术债务”列,每次因为赶进度而妥协的地方,都记一张卡片,写清楚妥协了什么、风险是什么、建议什么时候还。每个迭代留出 10% 到 20% 的容量专门还债。这样既不会因为追求完美而拖慢交付,也不会让债务越积越多最后无法收拾。
有一类债务特别危险,就是测试覆盖率的债务。核心逻辑没有测试,改一行代码就心惊胆战。我的经验是,新代码必须带测试,老代码在修改的时候顺手补测试。不要想着一次性把所有老代码的测试都补上,那不现实。每次改到哪个模块,就把那个模块的测试补到 70% 以上,日积月累,整体覆盖率自然就上来了。
7. 面向未来的技术储备建议
7.1 值得关注的新兴方向
2025 年有几个方向值得 .NET 开发者提前布局。第一个是边缘计算,随着物联网设备的普及,在边缘节点上跑轻量级的 .NET 服务成为可能。AOT 编译让 .NET 服务可以跑在资源受限的设备上,这是一个新的应用场景。第二个是AI 集成,不是让你去训练大模型,而是把现有的 AI 能力集成到 .NET 应用里,比如用Microsoft.Extensions.AI这个库来统一调用不同的 AI 服务。第三个是WebAssembly,Blazor 的 WASM 模式在性能上持续优化,一些计算密集型的场景已经可以考虑把部分逻辑放到前端执行。
这些方向不需要全部投入,选一个和你当前工作最相关的深入进去就行。我的建议是先从 AI 集成入手,因为它的学习曲线最平缓,而且能快速在现有项目里看到效果。比如给一个客服系统加一个智能问答的功能,用现成的 AI 服务加上 .NET 的 HTTP 客户端就能做,不需要太多额外的知识。
7.2 学习路径与资源筛选
学习资源不在多,在精。官方文档永远是第一选择,尤其是learn.microsoft.com上的教程和示例代码,质量很高而且更新及时。其次是 GitHub 上的官方示例仓库,比如dotnet/samples,里面的代码都是经过验证的。社区博客可以看,但要注意甄别,有些文章是几年前写的,里面的 API 已经过时了。
我的学习习惯是:遇到一个新特性,先看官方文档的概述,然后找一个最小的示例跑起来,跑通之后再去看源码或者深入的文章。不要一上来就啃大部头,那样容易劝退。另外,参加本地的技术社区活动也很有帮助,面对面交流能获得很多文档里没有的实战经验。
7.3 个人项目与实战演练的建议
光学不练假把式。我建议每个 .NET 开发者都维护一个自己的小项目,不用很复杂,但要是完整的、能跑起来的。比如一个个人书签管理工具、一个定时抓取天气数据的小服务、或者一个简单的博客系统。关键是要覆盖完整的开发流程:需求定义、数据库设计、API 开发、前端对接、部署上线。走完一遍,你对整个技术栈的理解会深刻很多。
我自己维护了一个开源的命令行工具,用来批量处理图片。功能很简单,但我在里面实践了 AOT 编译、单元测试、CI/CD 流水线、NuGet 打包发布这些流程。每次 .NET 出新版本,我就拿这个项目来试新特性,踩坑的成本很低,但收获的经验可以直接用到工作中。这个习惯我坚持了五年,受益良多。
写到这里,我想起一个前辈说过的话:技术的变化很快,但解决问题的思路是相通的。.NET 每年都在更新,新的语法、新的库、新的工具层出不穷,但只要你掌握了调试的方法、性能优化的逻辑、架构设计的权衡,换一个版本、换一个框架,你依然能快速上手。这份年度总结里的具体技术点可能会过时,但里面关于如何定位问题、如何做技术选型、如何管理技术债务的思路,应该能管用一段时间。希望这些从一线实战中攒下来的经验,能帮你少踩几个坑,多省几个小时。