1. Modbus 协议中,线圈(Coil)和保持寄存器(Holding Register)在数据类型、功能码和使用场景上有什么区别?

线圈是一个位,只有 0 和 1,对应 PLC 的开关量输出,比如继电器通断、启停信号。寄存器是16 位的一个字,存数值,比如温度设定值、转速、模拟量。

功能码不一样:读线圈是 01,写单个线圈 05,写多个 15;读保持寄存器是 03,写单个 06,写多个 16。另外还有只读的:02 离散输入(位),04 输入寄存器(字)。

有个坑值得提一句:05 功能码写线圈,数据段是 0xFF00 表示 ON、0x0000 表示 OFF,不是写 1 和 0,很多人第一次写会踩。

还有就是寄存器要注意字节序,32 位的浮点数要占两个寄存器,不同厂商的高低字顺序(ABCD / CDAB)不一样,得按设备手册来。


2. C# 中什么情况下会产生死锁?常见的死锁场景有哪些?你在项目里是怎么排查和规避的?

死锁就是两个线程互相拿着对方要的锁,谁也不放,一起卡死。最经典的:A 线程拿了 lock1 去要 lock2,B 线程拿了 lock2 去要 lock1。

C# 里还有一类特别容易踩的,是异步转同步造成的死锁——在 WinForm/WPF 或者老的 ASP.NET 里对 async 方法调 .Result.Wait(),主线程被阻塞了,而 await 后面的代码又要回到主线程执行,就锁死了。

避免的几条:

  • 统一加锁顺序,所有地方都按同一个顺序拿锁
  • 锁粒度尽量小,锁里面绝对不要调外部代码、不要做 IO 和网络请求
  • Monitor.TryEnter 带超时,拿不到就退出别死等
  • async 要一路到底,别混同步;库代码加 ConfigureAwait(false)
  • 能不用锁就不用,简单计数用 Interlocked,集合用 ConcurrentDictionary

排查的话,本地用 VS 的并行堆栈窗口一眼就能看出互相等待;线上就 dotnet-dump 抓个转储,分析线程栈。


3. 什么是缓存雪崩?在高并发场景下你会用哪些手段来预防和兜底?

雪崩有两种:一种是大批 key 在同一时刻集体过期,另一种是Redis 整个挂了,结果都是请求全砸到数据库上,数据库直接被打死。

针对第一种,最简单有效的就是过期时间加随机值,比如基础 30 分钟再加 0 到 5 分钟的随机,把过期时间打散。真正的热点数据我一般设成永不过期,后台定时刷新

针对第二种,靠架构冗余:主从加哨兵,或者直接上 Cluster,保证单点挂了还能用。

另外还有两层兜底:

  • 多级缓存,本地内存缓存扛一层,Redis 挂了本地还能顶一会儿
  • 限流 + 熔断降级,用 Polly 做,数据库快撑不住的时候直接返回降级数据或者友好提示,保住数据库比返回全部数据重要

4. 缓存击穿和缓存雪崩、缓存穿透的区别是什么?针对热点 key 失效瞬间的高并发你怎么处理?

击穿是单个热点 key 过期的那一瞬间,成千上万个请求同时发现缓存没了,一起去查数据库。跟雪崩的区别是——雪崩是一大片 key,击穿是一个特别热的 key。

两个主流解法:

一是互斥锁。 用 Redis 的 SET NX 抢一把分布式锁,只放一个线程去数据库回源,其他线程要么等一会儿重试,要么直接返回旧值。注意锁要设过期时间,防止持锁的线程挂了。

二是逻辑过期。 缓存值本身不设 TTL,但在 value 里存一个逻辑过期时间。读到发现逻辑上过期了,就先把旧数据返回去,同时起个异步任务去重建缓存。用户永远不等待,代价是短时间内会读到旧数据。

顺带说一下穿透——查一个数据库里根本不存在的 key,缓存永远不命中。解法是空值也缓存(设个短 TTL),或者上布隆过滤器先挡一层。


5. 用 Nginx 做负载均衡时,后端某个实例宕机了怎么自动摘除?服务上下线是怎么感知的?除了 Nginx,你还用过哪些网关方案?

不用手写网关,这个轮子太成熟了。

Nginx 本身就有被动健康检查max_failsfail_timeout,比如配失败 3 次就把这个节点摘掉 30 秒,之后再试探性放流量进去。开源版没有主动探活,要主动探活得用 Nginx Plus 或者 OpenResty 加模块。

但只靠 Nginx 有个问题——配置是静态的,扩容缩容得改配置。所以正经做法是配注册中心:服务启动时往 Consul / Nacos 注册并发心跳,节点挂了心跳断了自动摘除,再用 consul-template 之类的把 upstream 动态刷进 Nginx。

如果是纯 .NET 技术栈,我更倾向直接用 YARP,微软官方的反向代理,就是个 NuGet 包,健康检查、负载策略、灰度都能在代码或配置里写,调试和二次开发比 Nginx 顺手很多。同类的还有 Ocelot、APISIX、Traefik。

最后,网关层做了摘除还不够,客户端侧也要有重试和熔断,我们用 Polly;发版的时候要优雅下线——先从注册中心摘掉、等存量请求处理完,再停进程,不然照样会有一批 502。


6. MongoDB 存海量 IoT 时序数据时,怎么做水平拆分?分片键要怎么选?除了分片还有别的拆分手段吗?

MongoDB 里没有"分表"这个说法,对应的机制叫分片(Sharding),而且是数据库原生支持的,不像 MySQL 要靠 ShardingSphere 这类中间件。

架构是三件套:mongos 做路由 + config server 存元数据 + 若干个 shard 分片(每个分片是一个副本集)

最关键的是选片键。IoT 数据最容易犯的错是拿时间戳当片键——时间是单调递增的,所有新写入永远落在最后一个分片上,直接热点,等于没分。正确做法是用 deviceIdhashed 分片把写入打散,或者用 {deviceId: 1, ts: 1} 这样的复合片键,既打散写入,又能让"查某个设备某段时间"的查询定位到单个分片。

另外两点在 IoT 场景很实用:

  • 时序集合(Time Series Collection),5.0 之后的特性,内部自动按时间和 metaField 分桶存储,压缩率和写入性能比普通集合好一大截,是专门为这种场景设计的
  • 按时间分集合,比如 data_202608 一个月一个集合,查询时按时间路由,好处是归档和删除极快——直接 drop 整个集合,比 deleteMany 快无数倍

7. 单集群年增 40TB 的数据规模,你会从哪些维度去设计存储方案?成本和查询性能怎么平衡?

40TB 这个量级,核心思路就一句:不可能也没必要全放在热存储里。我会分四步做。

第一步,先把体积压下来。 用时序集合,配 zstd 压缩,IoT 这种数值型数据压缩比很可观,很多时候能压到原来的三分之一甚至更少。

第二步,做降采样。 原始的秒级数据只保留 7 到 30 天,之后聚合成分钟级、小时级存到另一个集合。真实业务里查半年前的数据,没人要看当时每一秒的值,看趋势就够了。光这一步就能砍掉绝大部分体积。

第三步,冷热分层。Zone Sharding 把近三个月的热数据锁在 SSD 的分片上,历史数据迁到便宜的大容量机械盘分片,再老的直接归档成 Parquet 扔对象存储。配合 TTL 索引自动删过期数据。

第四步,分片横向扩容,按 deviceId 哈希打散,扩容就是加机器。

最后我会补一句:这个量级我其实会重新评估选型。纯时序场景 TDengine 或 ClickHouse 的列式存储加编码压缩,压缩比和聚合查询性能都比 MongoDB 强不少,Mongo 更适合放设备档案、配置这类文档型数据。选型上我倾向元数据放 Mongo/PG、时序点位放专门的时序库


8. 单条 UPDATE 语句更新一行的多个字段,数据库能保证这次更新的原子性吗?其他事务有没有可能读到"更新到一半"的中间状态?

能保证,单条语句对单行的更新是原子的。要么这几个字段全部更新成功,要么全部不生效,不存在改了三个字段剩两个没改的情况。

底层靠两个东西:行锁保证同一时刻只有一个事务能改这行;undo log 和 redo log 保证就算中途宕机,重启后也能通过日志把这行恢复到一致状态。

不过这里要区分两个概念:原子性不等于隔离性。别的事务能不能看到中间状态,取决于隔离级别。MySQL 默认是可重复读,通过 MVCC 读快照,读到的一定是某个完整版本,不会是半拉子数据。

还有一点得说清楚:这个保证只覆盖"一条语句、一行数据"。如果是多行更新,或者连续几条 UPDATE 要一起成功,那必须显式开事务,否则中间挂了就是脏数据。


9. 事务开启后既不提交也不回滚,会产生什么影响?长事务对数据库有哪些危害?

短期看是"卡住",长期看是"拖垮数据库"。

首先,事务持有的行锁和间隙锁不会释放。别人要改同一行就一直阻塞,等到 innodb_lock_wait_timeout(默认 50 秒)超时报错。业务上表现就是接口大面积超时。

其次,undo log 清理不掉。MVCC 要保证这个老事务还能读到它开始时的快照,所以它之前的所有历史版本都不能被 purge 线程回收,回滚段会一直膨胀,磁盘涨得很快。这是长事务最阴的地方——问题不出在事务本身,而是拖累了整个实例的版本回收

另外连接也一直被占着,连接池很快耗尽。

不过有个兜底:如果连接最终被关闭或者被连接池回收,MySQL 会自动回滚这个未提交的事务,数据不会错,只是这期间数据库已经被拖了很久了。

实践上我的原则是:事务一定用 using 或者 try/finally 包住保证释放;事务范围尽量短,绝对不在事务里发 HTTP 请求、调第三方接口、或者做耗时计算——先把这些做完,再开事务写库。


10. MQTT 在公网上传输数据,怎么保证传输安全?除了加密,鉴权和权限控制这块你是怎么做的?

核心就一条:上 TLS,用 8883 端口的 mqtts,服务端配证书,链路整个加密。这是最基本的,公网裸跑 1883 明文等于把数据挂在网上。

安全要求高的场景做双向认证(mTLS),客户端也带证书,服务端验证设备身份,等于顺便把身份认证也做了。

光加密不够,还得配鉴权和权限:

  • 禁止匿名连接,一机一密的用户名密码,或者签发有效期的 JWT Token
  • ACL,限制每个设备只能发布和订阅自己的 topic。这点特别重要——不然一台设备被攻破,攻击者可以订阅 # 把全网数据全收走,加密就白做了
  • topic 里不要带敏感信息,topic 名本身在很多 Broker 的日志里是明文

如果是低算力的单片机跑不动 TLS 握手,退一步做 payload 层加密,用 AES 加密消息体,密钥一机一密,至少保证内容不泄露。

最后加一句:服务端别裸奔在公网,前面挂负载均衡、限制 IP、开连接频率限制,防止被暴力连接打爆。


11. MQTT 的三个 QoS 等级分别是什么语义?各自的交互过程和适用场景是什么?实际项目里你会怎么选?

  • QoS 0,最多一次。 发出去就不管了,没有确认,网络抖一下就丢。开销最小最快。适合高频采集的遥测数据,丢一两个点无所谓,反正下一秒又来了。
  • QoS 1,至少一次。 发完等 PUBACK,没收到就重发,所以保证不丢,但可能重复。这是绝大多数场景用的等级。
  • QoS 2,恰好一次。 四次握手,PUBLISH → PUBREC → PUBREL → PUBCOMP,不丢也不重。代价是交互次数多、Broker 要存状态,吞吐最低。

选型上我的经验是:90% 的场景用 QoS 1,然后在业务层做幂等——消息里带个唯一 ID,服务端用 Redis 或者数据库唯一索引去重。这样比用 QoS 2 划算得多,因为 QoS 2 的性能开销是实打实的,而幂等逻辑你不管用哪个 QoS 其实都该有。

只有那种真的不能重复执行的指令才考虑 QoS 2,比如扣费、一次性的控制动作。

另外要注意,QoS 是逐跳的:发布端到 Broker 一个 QoS,Broker 到订阅端又是一个 QoS,最终等级取两者中较低的那个。


12. 需要从 MySQL 取出百万级数据时,怎么避免内存溢出和慢查询?分页、导出、统计这几种场景分别怎么处理?

我一般先反问一句:这 100 万行取出来是干嘛用的,因为不同目的方案完全不一样。

如果是给人看的分页——没人会翻 100 万行,一定是分页。这时候的重点是别用 LIMIT 1000000, 20,因为它要先扫描并丢弃前 100 万行,越翻越慢。改成游标分页WHERE id > @lastId ORDER BY id LIMIT 20,走主键索引,翻到多少页都是恒定速度。

如果是导出或者跑批——流式读取,用 MySqlDataReader 一行一行读、边读边处理,绝对不要一次性 ToList() 或者塞进 DataTable,那是 OOM 的标准写法。写入另一张表的话用 SqlBulkCopy / MySqlBulkLoader 批量灌。

如果是统计聚合——能在 SQL 里算完就别拉到应用层,或者干脆走 OLAP 库、提前跑好预聚合的宽表。

不管哪种,有几条是通用的:

  • 只 select 需要的列,别 select *,尽量走覆盖索引避免回表
  • 避免大事务,分批提交
  • 绝对不能在循环里查库,也就是 N+1 问题

13. 聚集索引和非聚集索引在存储结构上有什么区别?什么是回表?主键设计上有哪些讲究?

聚集索引就是数据本身。它的叶子节点存的不是指针,而是整行数据,所以数据在磁盘上的物理顺序就是按聚集索引排的。正因为一份数据只能有一种物理顺序,一张表只能有一个聚集索引。InnoDB 里聚集索引就是主键,没定义主键就找第一个非空唯一索引,再没有就生成一个隐藏的 rowid。

非聚集索引(二级索引)的叶子节点存的是索引列 + 主键值。所以拿二级索引查其他字段的时候,得先查到主键,再回聚集索引查一次完整行,这个过程叫回表

由此引出几个实践点:

  • 主键要短、要单调递增。用自增 ID 或者雪花 ID,别用无序的 GUID——因为聚集索引要按主键排序存,插入无序值会导致频繁页分裂,性能和空间都受影响。而且主键会被冗余到每一个二级索引里,主键越长,所有索引都跟着变大
  • 覆盖索引:如果要查的字段都在二级索引里,就不用回表,性能提升非常明显,这是索引优化最常用的一招

补一句 SQL Server 的差异:概念一样,但 SQL Server 允许表没有聚集索引,那种叫堆表,行的位置靠 RID 定位。


14. 相比 MySQL,PostgreSQL 的核心优势体现在哪些方面?除了 pgvector,还有哪些常用扩展?

PG 的优势我一般讲三点:

一是 SQL 标准和功能的完整度最高。 窗口函数、递归 CTE、物化视图、FILTERLATERAL 这些都很成熟,复杂分析类 SQL 写起来舒服很多。

二是类型系统极其丰富。 JSONB 是真正可索引可查询的,不是当字符串存;还有数组、范围类型、枚举、几何类型、IP 类型,甚至能自定义类型和操作符。

三是可扩展性。 索引类型多、支持表达式索引和部分索引、存储过程能用多种语言写,还有整个插件生态。

插件除了 pgvector,常用的有:

  • PostGIS —— 地理空间的事实标准,这块 MySQL 完全比不了
  • TimescaleDB —— 时序扩展,超表 + 自动分区 + 列式压缩,IoT 场景很常用
  • Citus —— 分布式分片,把 PG 变成分布式数据库
  • pg_stat_statements —— 慢 SQL 统计,我认为是必装的
  • pg_trgm —— 三元组索引,能让 LIKE '%xx%' 走索引
  • postgres_fdw / oracle_fdw —— 外部数据包装器,跨库联邦查询
  • pg_partman —— 自动创建和维护分区
  • 还有 pgcrypto、hstore、uuid-ossp 这些小工具

15. 能讲讲 Kafka 的核心架构和设计思想吗?它为什么吞吐量这么高?和 RabbitMQ 的定位差异在哪?

Kafka 本质上是一个分布式的提交日志,不太像传统消息队列,更像一个可以重放的数据管道。

核心概念: 一个 Topic 分成多个 Partition,每个 Partition 是一个只能追加写的有序日志文件,每条消息有个 offset。Producer 按 key 做哈希决定进哪个分区,相同 key 一定进同一分区,所以能保证局部有序。消费端是消费组模型,组内一个分区只能被一个消费者消费,所以消费并发度的上限就是分区数——这点很关键,分区数没设够,加再多消费者也没用。

为什么快: 三个原因——磁盘顺序写(顺序写磁盘不比随机写内存慢)、零拷贝(sendfile 直接从页缓存发到网卡)、批量 + 压缩

高可用靠副本机制,每个分区有 leader 和 follower,ISR 是跟上进度的副本集合,leader 挂了从 ISR 里选新的。

还有个和 MQ 很不一样的点:消息消费完不删除,按时间或大小保留,所以可以重放——这个特性做数据回溯、修 bug 重跑非常有用。

和 RabbitMQ 的区别: Kafka 是高吞吐的流平台,拉模式,适合日志、埋点、IoT 海量数据管道;RabbitMQ 路由灵活(各种 Exchange)、延迟低、支持延迟队列死信队列,适合业务解耦和任务分发。选型看场景,不是谁替代谁。

.NET 这边用 Confluent.Kafka,官方维护的,很稳。新版本 Kafka 用 KRaft 已经不依赖 ZooKeeper 了。


16. 怎么理解 AOP?它解决的是什么问题?请结合你实际项目中的一个场景讲讲是怎么落地的

AOP 解决的是横切关注点的问题——就是那些和业务逻辑没关系、但每个方法都得写一遍的代码:日志、事务、缓存、权限校验、重试、性能监控。

如果不用 AOP,这些代码会散落在几百个方法里,改一次要改几百处,而且业务代码被淹没在样板代码里。AOP 的做法是把它们抽成独立的切面,在不改业务代码的前提下织入进去

.NET 里的实现方式挺多:ASP.NET Core 的中间件和过滤器(ActionFilter)本身就是 AOP;更细粒度的用 DI 拦截器,比如 Castle DynamicProxy、AspectCore;还有编译期织入的 Fody 和源生成器。

举个我做过的例子——设备操作审计。 我们的系统里所有下发给设备的控制指令都必须留痕:谁、什么时候、对哪台设备、下了什么参数、成功还是失败、耗时多久。合规要求,一条都不能少。

如果在每个控制方法里手写日志,第一是重复,第二是总有人忘了写,出事的时候查不到。我的做法是定义一个 [Audit] 特性,配一个拦截器:方法执行前记录调用方和入参,执行后记录返回值和耗时,抛异常就记异常堆栈,统一写进审计表。业务方法上只加一行特性,里面只管业务逻辑。

后来事务也用同样的思路做了 [Transactional],拦截器负责 Begin / Commit / Rollback,业务代码干净很多。


17. PostgreSQL 支持哪些索引类型?各自适用于什么数据和查询场景?

PG 索引类型比 MySQL 丰富得多,常用的六种:

  • B-tree —— 默认,等值和范围查询,90% 的场景用它
  • Hash —— 只支持等值,用得很少,B-tree 基本能覆盖
  • GIN —— 倒排索引,一个字段包含多个值的场景,比如 JSONB、数组、全文检索,配 pg_trgm 还能加速 LIKE '%xx%'
  • GiST —— 通用搜索树,几何和空间数据(PostGIS 的基础)、范围类型、最近邻查询
  • SP-GiST —— 空间分区树,适合非平衡的数据结构,比如 IP 地址、点数据
  • BRIN —— 块范围索引,只存每个数据块的最大最小值,索引体积极小。前提是数据在物理上跟索引列顺序相关。IoT 和日志表按时间递增插入就完美符合,几十 GB 的表索引可能只有几 MB

另外三个很实用的用法,我觉得比索引类型本身更常用:

  • 部分索引 —— CREATE INDEX ... WHERE status = 'active',只给需要查的那部分建索引,体积小很多
  • 表达式索引 —— CREATE INDEX ON t (lower(email)),让函数查询也能走索引
  • 覆盖索引 —— INCLUDE (col),把额外字段带进索引避免回表

18. C# 中 event 和 delegate 的本质区别是什么?event 在封装上做了哪些限制?分别在什么场景下使用?

一句话:委托是类型,事件是基于委托的一层封装,加了访问限制。

委托本质是类型安全的函数指针,可以当字段、当参数、当返回值。事件是在委托字段外面包了一层,编译后会生成 addremove 两个访问器,字段本身变成 private。

关键区别就两条,都在于"限制外部能干什么":

  1. 委托字段如果是 public,外部可以用 = 直接赋值,一不小心就把别人注册的所有订阅覆盖掉了。事件在类外只能 +=-=,赋值编译不通过。
  2. 委托字段外部可以直接 Invoke 触发。事件只有声明它的类内部才能触发,外部想主动触发是不允许的。

所以用法很清晰:对外暴露"通知"用 event——我通知你发生了什么,你只能订阅,不能替我决定谁能收、也不能替我发。回调传参用委托,比如 Func<T>Action<T> 当方法参数传进去。

补一个实践细节:触发事件前要判空,标准写法是 MyEvent?.Invoke(this, args),多线程下用 ?. 已经能避免竞态(编译器会先取本地副本)。


19. .NET 的垃圾回收机制是怎么工作的?分代模型是怎么划分的?大对象堆有什么特殊之处?平时有哪些减轻 GC 压力的写法?

.NET 的 GC 是分代 + 标记清除 + 压缩

分代是核心思想,基于一个经验:大部分对象都是朝生夕死。所以分成:

  • Gen 0 —— 新分配的对象,回收最频繁、也最快,绝大多数对象在这里就被干掉了
  • Gen 1 —— Gen 0 回收后活下来的,相当于缓冲区
  • Gen 2 —— 长生命周期对象,比如单例、静态缓存。回收 Gen 2 就是 Full GC,最贵
  • LOH(大对象堆) —— 大于等于 85000 字节的对象直接进这里,逻辑上属于 Gen 2,而且默认不压缩,所以容易产生内存碎片
  • POH(固定对象堆) —— .NET 5 之后新增,放 pinned 对象,避免它们卡在普通堆里妨碍压缩

回收过程:GC Roots(线程栈上的局部变量、静态字段、GC 句柄、寄存器)出发做可达性分析,标记所有能访问到的对象,剩下的就是垃圾,清理掉之后压缩内存,把存活对象挪到一起消除碎片,同时更新引用。

模式上有工作站 GC 和服务器 GC(多核多堆多线程,服务端默认),还有后台 GC,让 Gen 2 的回收和应用线程并发执行,减少 STW 停顿。

实践上减轻 GC 压力:

  • 少造大对象,尤其避免频繁分配超过 85K 的数组,用 ArrayPool<T> / MemoryPool<T> 复用
  • 热路径上用 Span<T>struct,减少堆分配
  • 别乱写终结器(析构函数),有终结器的对象要多活一代,进终结队列;非托管资源用 IDisposable + using
  • 大字符串拼接用 StringBuilder

20. 时序数据库相比关系型数据库,在存储结构、写入模型和查询能力上做了哪些针对性优化?什么场景下你会选时序库?

时序库是针对**"时间戳 + 设备/指标标签 + 数值"这种数据形态**做的特化。这类数据的特点是:只追加、几乎不更新不删除、写多读少、按时间范围查。关系库是通用设计,面对这种数据就不划算了。

差别主要在四个方面:

存储: 时序库按时间分区 + 列式存储,同一列数据类型相同、数值相近,可以用 delta-of-delta、Gorilla 这类编码压缩,压缩比轻松到 10:1 甚至更高;关系库是行存,压缩空间有限。

写入: 时序库为高频顺序追加优化,百万点每秒是常规水平;关系库是 B+ 树,随机写加上维护多个二级索引,写入放大严重。

查询: 时序库内置时间函数——降采样、插值、滑动窗口、first/last、时间对齐,一行 SQL 搞定;关系库要写一堆窗口函数和自连接。

生命周期管理: 时序库自带保留策略、自动过期、自动降采样,配好就不用管了;关系库得自己写定时任务删数据。

但时序库有明显短板:不擅长复杂 JOIN、没有强事务、不支持频繁 UPDATE,也不适合存关系型的业务数据。

所以实际架构一般是两种库配合:设备档案、用户、权限、订单这些放 PostgreSQL / MySQL,采集点位数据放 TDengine / InfluxDB / TimescaleDB,业务查询的时候在应用层组装。


21. gRPC 的底层机制是什么?相比 REST 有哪些优势和局限?在 .NET 项目中你会在什么场景下选用它?

gRPC 是 Google 的 RPC 框架,两个基石:HTTP/2 + Protobuf

Protobuf 是二进制序列化,比 JSON 体积小很多、序列化也快;而且是强契约——写一份 .proto 文件,能生成各种语言的客户端和服务端代码,跨语言协作非常省事,接口对不上在编译期就报错,不用等到运行时。

HTTP/2 带来多路复用(一条连接跑多个请求,不用像 HTTP/1.1 那样排队)、头部压缩、长连接。

四种调用模式是它比 REST 强的地方:一元调用、服务端流、客户端流、双向流。做实时推送、大文件分块传输、长连接指令下发都很自然,不用额外上 WebSocket。

适合的场景: 内部微服务之间的调用、需要流式通信、多语言混合的团队。

局限也得说清楚:

  • 浏览器不能直接调 gRPC,得走 gRPC-Web 代理,或者用 gRPC-JSON transcoding 转成 REST
  • 调试没有 REST 直观,不能拿浏览器和 curl 直接试,得用 grpcurl 之类的工具
  • proto 演进要守规矩:字段编号一旦用了就不能改、不能复用,删字段要 reserved,否则会出现难查的兼容性问题

.NET 这边支持很好,Grpc.AspNetCore 由 Kestrel 直接托管,性能不错,还能同时开 gRPC-Web 给前端用。


22. SignalR 服务端怎么控制并发连接数?既要限制总连接数,也要限制单用户的连接数,分别怎么实现?集群部署下要注意什么?

SignalR 本身没有直接的"最大连接数"配置项,得分几层来做:

第一层,业务层自己计数——这是最灵活也最常用的。在 Hub 的 OnConnectedAsync 里用 Interlocked.Increment 做全局计数,超过阈值就直接 Context.Abort() 或者抛异常拒绝;OnDisconnectedAsync 里减回来。

按用户限制同理:用一个 ConcurrentDictionary<userId, 连接数>,同一个用户超过比如 3 个连接,就把最早的那个连接踢掉,或者拒绝新连接。防止一个人开几十个标签页。

第二层,基础设施层兜底——Kestrel 的 MaxConcurrentConnections,或者在 Nginx 上配 limit_conn 做 IP 级别的连接数限制,再加 AspNetCoreRateLimit 做请求频率限流。

另外几个必配的选项,虽然不是限连接数,但直接关系到服务能不能扛住:

  • ClientTimeoutIntervalKeepAliveInterval —— 及时清理掉线的僵尸连接,不然连接数只涨不降
  • MaximumReceiveMessageSize —— 限制单条消息大小,防止有人发个超大包把内存打爆
  • StreamBufferCapacity —— 限制流式缓冲

最后一个重点:集群部署。多实例的时候必须配 Redis backplane(或 Azure SignalR Service)做消息广播,同时连接计数也必须放 Redis,否则每个实例各算各的,限制就不准了。


23. 百万行数据导出 Excel,怎么避免内存溢出和请求超时?读取端、写入端、交互流程分别要怎么设计?

这题的坑就一个:不能一次性把数据装内存。用 EPPlus 或 NPOI 的普通模式建 100 万行的工作簿,必 OOM。而且 xlsx 单个 sheet 上限是 1048576 行,正好卡在 100 万这个量级。

我会从三个方面处理:

一、交互流程改成异步任务。 接口不直接返回文件,而是丢一个后台任务、立刻返回任务 ID。后台生成完把文件传到对象存储,再通过 SignalR 或者站内信通知用户下载。不能让用户对着浏览器等好几分钟,网关和浏览器都会超时。

二、读取端流式化。DbDataReader 逐行读,或者按主键游标分批拉,边读边写边释放,内存占用保持恒定。绝对不能先 ToList() 全查出来。

三、写入端用支持流式的库。

  • MiniExcel 是我的首选,SaveAs 可以直接吃 IEnumerable 或者 IDataReader,内存几乎恒定,代码还特别简单
  • 或者用 OpenXML SDK 的 SAX 模式OpenXmlWriter),性能最好但代码繁琐
  • NPOI 的 SXSSF 也可以,它会把行溢写到临时文件

另外几个实用点:

  • 超过 100 万行要拆 sheet 或拆文件,比如每 50 万行一个 sheet,多文件就打包成 zip
  • 如果对格式没硬要求,直接导 CSV,速度快一个数量级,几百 MB 也没压力,用户拿 Excel 一样能打开
  • 文件写到磁盘或对象存储,不要在内存里拼 byte 数组

24. 什么是波特率?它和比特率是一回事吗?波特率的高低对通信有什么影响?串口通信除了波特率还有哪些参数必须匹配?

波特率是每秒传输的符号数。在串口这种一个符号就是一个比特的场景下,它数值上等于每秒比特数,所以平时说 9600 波特就是一秒传 9600 位。严格讲波特率和比特率是两个概念,只是串口里恰好相等。

换算的时候有个容易忽略的点: 串口传一个字节实际要发 1 个起始位 + 8 个数据位 + 1 个停止位 = 10 位,所以 9600 波特的实际有效速率大概是 960 字节每秒,不是 1200。算轮询周期的时候得按这个来。

高低波特率的区别:

  • 高波特率传得快,但抗干扰能力差、传输距离短,对线缆质量和两端时钟精度要求高。时钟稍有偏差,采样点就偏了,直接乱码
  • 低波特率慢,但稳定、能跑更远

所以工控现场 RS485 长距离的场景,很多都老老实实用 9600 或 19200,图的就是稳;短距离板级通信才上 115200 甚至更高。

最关键的一点:收发双方波特率必须完全一致,差一点就是满屏乱码——这是串口调试最常见的第一个问题。

除了波特率,数据位、停止位、校验位、流控也都得对齐。工控里最常见的组合是 9600-8-N-1(9600 波特、8 数据位、无校验、1 停止位)。另外 Modbus RTU 还有帧间隔要求(3.5 个字符时间),波特率变了这个超时时间也要跟着调。


25. C# 里有哪些线程同步手段?各自适用于什么场景?异步方法里为什么不能用 lock,应该用什么替代?

lock 本质是 Monitor.Enter / Exit 的语法糖。除了它和 SemaphoreSlim,常用的还有这么几类:

轻量原子操作:

  • Interlocked —— 原子的自增、交换、CAS,最轻量,做计数器、标志位首选,比 lock 快一个数量级

各种锁:

  • Monitor.TryEnter —— 带超时的 lock,拿不到就走别的逻辑,可以防死锁
  • ReaderWriterLockSlim —— 读多写少的场景,多个读线程可以同时进,写的时候独占
  • Mutex —— 跨进程同步,比如保证程序只启动一个实例
  • SpinLock —— 临界区极短的时候用,自旋等待不做线程切换,省上下文切换开销

线程间信号协调:

  • ManualResetEventSlimAutoResetEventCountdownEventBarrier

其实更推荐的方向是"别自己加锁":

  • 并发集合 —— ConcurrentDictionaryConcurrentQueueBlockingCollection,内部用无锁或细粒度锁实现,比自己在外面套 lock 又快又不容易错
  • Channel<T> —— 生产者消费者模型的现代写法,比自己写阻塞队列好用
  • 不共享状态 —— 用不可变对象、ThreadLocal、每个线程一份数据最后合并,从根上消除竞争。Parallel.For 的局部状态聚合就是这个思路

两个必须提的注意点:

  1. lock 里面不能 await。异步场景要互斥得用 SemaphoreSlim.WaitAsync(),或者自己封一个 AsyncLock。
  2. lock 的对象要用私有的 readonly object,别锁 thistypeof(T) 或者字符串字面量——这些外部也能拿到,很容易造成意料之外的死锁。

最后补一句 volatile:它解决的是可见性和指令重排序,不是互斥,别拿它当锁用。


26. List 和 Dictionary 的底层结构有什么区别?大数据量下查找性能差多少?各自常用操作的时间复杂度是多少?

List<T> 底层是一个连续的数组Dictionary<TKey,TValue> 底层是哈希表——一个桶数组加上冲突时的链式结构。结构不同,决定了它们的强项完全不一样。

时间复杂度对照:

操作 List<T> Dictionary<K,V>
按下标取值 list[i] O(1) 不支持
按内容查找 Contains / Find O(n)
按 key 查找 TryGetValue / ContainsKey O(1) 平均,最坏 O(n)
尾部添加 Add O(1) 摊还(扩容时 O(n)) O(1) 摊还
中间插入删除 Insert / RemoveAt O(n),要挪动后面所有元素
按 key 删除 Remove O(n) O(1)
遍历 O(n) O(n)

大数据量下差多少: 100 万条数据里找一个元素,List 平均要比较 50 万次,Dictionary 算一次哈希直接定位到桶。差距是几个数量级,而且数据量越大差得越多——List 是线性增长,Dictionary 基本恒定。

但 Dictionary 不是没有代价:

  • 内存开销大,每个条目除了 key 和 value,还要存 hashCode 和 next 指针,加上桶数组本身有冗余空间,整体大概是 List 的两三倍
  • 无序,遍历顺序不做保证,业务上绝对不能依赖
  • 纯遍历反而更慢。List 内存连续,CPU 缓存命中率高,全量遍历的场景 List 更快
  • 数据量很小的时候(几十个以内),List 线性查找可能比 Dictionary 还快,因为省掉了计算哈希的开销

最坏情况是所有 key 哈希冲突,退化成 O(n)。.NET Core 对 string key 做了随机化哈希,并且在单个桶冲突过多时会自动切换哈希算法,主要是为了防哈希碰撞攻击。

怎么选:

  • 靠下标访问、需要保持顺序、主要是遍历 → List
  • 靠 key 频繁查找 → Dictionary
  • 只判断存不存在、不需要 value → HashSet<T>
  • 既要有序又要能快速查找 → SortedDictionary(红黑树,O(log n))

最后说一个我实际踩过的坑,这题很容易被追问到实践:

// 外层循环 n 次,里面每次 O(n) 线性查找 → 整体 O(n²)
foreach (var order in orders)
    var user = users.FirstOrDefault(u => u.Id == order.UserId);

数据量上万就明显卡,上百万直接跑不动。改法很简单,先建索引再查

var userMap = users.ToDictionary(u => u.Id);   // O(n) 建一次
foreach (var order in orders)
    userMap.TryGetValue(order.UserId, out var user);   // 每次 O(1)

整体从 O(n²) 降到 O(n)。这是把时间复杂度知识用在实处最典型的例子。


写在最后

  • 每题的内容都比实际口述要长,面试时抓加粗的关键词讲,讲完停顿,让面试官决定要不要继续深挖。
  • 遇到"你们是怎么解决的"这类问题,先讲场景再讲方案,最好带上项目里的真实数字(数据量、QPS、耗时),比纯理论有说服力得多。
  • 不确定的直接说"这块我了解得不深,我的理解是……",然后讲你知道的部分。硬编是最扣分的
  • 第 5、7、12、23 题属于开放式架构题,没有标准答案,考的是思路。先反问需求和约束条件再给方案,这个动作本身就是加分项。