1. In the Modbus protocol, what are the differences between Coils and Holding Registers in terms of data types, function codes, and use cases?
A coil is a single bit, taking only the values 0 and 1, corresponding to a PLC's digital output, such as relay on/off states or start/stop signals. A register is a 16-bit word used to store numerical values, such as temperature setpoints, rotational speed, or analog values.
The function codes differ: reading coils uses 01, writing a single coil uses 05, and writing multiple coils uses 15. Reading holding registers uses 03, writing a single register uses 06, and writing multiple registers uses 16. There are also read-only types: 02 for discrete inputs (bits) and 04 for input registers (words).
One pitfall worth mentioning: when writing a coil with function code 05, the data field must be 0xFF00 for ON and 0x0000 for OFF, not 1 and 0. Many people trip up on this the first time.
Also, be mindful of byte order for registers. A 32-bit floating-point number occupies two registers, and the order of the high and low words (ABCD / CDAB) varies by manufacturer. Always refer to the device manual.
2. Under what circumstances does a deadlock occur in C#? What are common deadlock scenarios? How did you troubleshoot and avoid them in your projects?
A deadlock occurs when two threads each hold a lock the other needs, neither releases it, and both get stuck. The classic example: Thread A acquires lock1 and then requests lock2, while Thread B acquires lock2 and then requests lock1.
In C#, another common pitfall is deadlocks caused by blocking on asynchronous code—calling .Result or .Wait() on an async method in WinForm/WPF or legacy ASP.NET blocks the main thread, while the code after the await still needs to resume on the main thread, resulting in a deadlock.
Here are a few ways to avoid this:
- Enforce a consistent lock acquisition order across all code paths
- Keep lock granularity as small as possible; never call external code or perform I/O and network requests inside a lock
- Use
Monitor.TryEnterwith a timeout; if the lock cannot be acquired, exit gracefully instead of blocking indefinitely - Keep the async flow end-to-end; avoid mixing synchronous and asynchronous code; add
ConfigureAwait(false)in library code - Avoid locks when possible; use
Interlockedfor simple counters andConcurrentDictionaryfor collections
For debugging, use Visual Studio's Parallel Stacks window locally to quickly identify circular waits; in production, capture a dump with dotnet-dump and analyze the thread stacks.
3. What is a cache avalanche? In high-concurrency scenarios, what measures would you use to prevent and mitigate it?
There are two types of cache avalanche: one is a large batch of keys expiring collectively at the same moment, and the other is Redis going down entirely. In both cases, the result is that all requests hit the database, causing it to crash.
For the first type, the simplest and most effective solution is to add a random value to the expiration time. For example, set a base expiration of 30 minutes plus a random offset of 0 to 5 minutes to disperse the expiration times. For truly hot data, I generally set it to never expire and refresh it periodically in the background.
For the second type, rely on architectural redundancy: use master-slave replication with Sentinel, or deploy a Redis Cluster directly, to ensure the system remains available even if a single node fails.
Additionally, there are two fallback layers:
- Multi-level caching: Use local in-memory cache as a buffer layer, so that if Redis goes down, the local cache can sustain traffic for a short period.
- Rate limiting + circuit breaking and degradation: Implemented using Polly. When the database is on the verge of being overwhelmed, return degraded data or a friendly message directly. Protecting the database is more important than returning all data.
4. What are the differences between cache breakdown, cache avalanche, and cache penetration? How do you handle high concurrency when a hot key expires?
A cache breakdown occurs at the exact moment a single hot key expires, when tens of thousands of requests simultaneously discover the cache is gone and all hit the database at once. The difference from a cache avalanche is this—an avalanche involves a large batch of keys, while a breakdown involves one exceptionally hot key.
There are two mainstream solutions:
One is using a mutex lock. Use Redis's SET NX to acquire a distributed lock, allowing only one thread to fetch data from the database, while other threads either wait briefly and retry or return stale values directly. Note that the lock must have an expiration time set to prevent deadlocks if the thread holding the lock crashes.
The other is logical expiration. The cache value itself has no TTL set, but a logical expiration timestamp is stored within the value. When a read operation detects that the data is logically expired, it first returns the stale data and simultaneously triggers an asynchronous task to rebuild the cache. Users never have to wait, at the cost of potentially reading stale data for a short period.
Incidentally, let's discuss cache penetration—querying a key that does not exist in the database at all, resulting in a permanent cache miss. The solution is to cache null values (with a short TTL) or to implement a Bloom filter to block such requests upfront.
5. When using Nginx for load balancing, how do you automatically remove a backend instance if it goes down? How is service online/offline status detected? Besides Nginx, what other gateway solutions have you used?
No need to build a gateway from scratch; this wheel is already very mature.
Nginx has built-in passive health checks: max_fails and fail_timeout. For example, you can configure it to remove a node for 30 seconds after 3 failures, then tentatively resume traffic. The open-source version lacks active health checks; for active probing, you need Nginx Plus or OpenResty with additional modules.
However, relying solely on Nginx has a drawback—configuration is static, so scaling up or down requires config changes. The proper approach is to use a service registry: services register with Consul / Nacos on startup and send heartbeats. If a node goes down and the heartbeat stops, it is automatically removed. Then, use tools like consul-template to dynamically update the upstream configuration in Nginx.
If you are using a pure .NET tech stack, I prefer using YARP directly. It is Microsoft's official reverse proxy, available as a NuGet package. Health checks, load balancing strategies, and canary releases can all be defined in code or configuration, making debugging and customization much easier than with Nginx. Similar alternatives include Ocelot, APISIX, and Traefik.
Finally, gateway-level removal alone is not enough; the client side also needs retry and circuit breaking, which we handle with Polly. During deployments, perform a graceful shutdown—first remove the instance from the registry, wait for existing requests to complete, then stop the process. Otherwise, you will still see a batch of 502 errors.
6. How do you implement horizontal sharding when storing massive IoT time-series data in MongoDB? How should you choose the shard key? Are there other sharding strategies besides sharding?
MongoDB does not use the term "table partitioning." The corresponding mechanism is called sharding, and it is natively supported by the database, unlike MySQL, which relies on middleware like ShardingSphere.
The architecture consists of three components: mongos for routing + config servers for storing metadata + multiple shards (each shard is a replica set).
The most critical aspect is choosing the shard key. A common mistake in IoT data is using timestamps as the shard key—since time is monotonically increasing, all new writes always land on the last shard, creating a hotspot, which effectively negates the benefits of sharding. The correct approach is to use deviceId for hashed sharding to distribute writes evenly, or use a compound shard key like {deviceId: 1, ts: 1}, which both distributes writes and allows queries for "a specific device within a certain time range" to target a single shard.
Two additional features are highly practical in IoT scenarios:
- Time Series Collections, a feature introduced in version 5.0 and later, automatically bucket data internally by time and
metaField, offering significantly better compression ratios and write performance compared to regular collections, specifically designed for this use case. - Time-based collection partitioning, such as
data_202608with one collection per month, allows routing queries by time. The advantage is that archiving and deletion are extremely fast—simply dropping the entire collection is orders of magnitude faster than usingdeleteMany.
7. For a single cluster adding 40TB of data annually, what dimensions would you consider when designing the storage solution? How would you balance cost and query performance?
At the scale of 40TB, the core idea is simple: it’s neither possible nor necessary to keep everything in hot storage. I’d approach this in four steps.
Step 1: Reduce the volume. Use time-series collections with zstd compression. For numeric IoT data, compression ratios are often impressive—frequently reducing size to one-third or even less of the original.
Step 2: Apply downsampling. Keep raw second-level data for only 7 to 30 days, then aggregate it into minute- and hour-level data stored in separate collections. In real-world scenarios, when querying data from six months ago, no one needs to see every single second’s value—trends are sufficient. This step alone eliminates the vast majority of the storage volume.
Step 3: Implement hot/cold tiering. Use Zone Sharding to pin the last three months of hot data to SSD shards, migrate historical data to cheaper, high-capacity HDD shards, and archive older data as Parquet files in object storage. Pair this with TTL indexes to automatically delete expired data.
Step 4: Scale shards horizontally. Distribute data using deviceId hashing; scaling out simply means adding more machines.
Finally, a note: at this scale, I’d actually reconsider the technology choice. For pure time-series workloads, TDengine or ClickHouse—with their columnar storage and encoding-based compression—deliver significantly better compression ratios and aggregation query performance than MongoDB. MongoDB is better suited for document-type data like device profiles and configurations. My preferred architecture is: store metadata in Mongo/PG, and time-series points in a dedicated time-series database.
8. When a single UPDATE statement updates multiple fields of one row, can the database guarantee the atomicity of this update? Is it possible for other transactions to read an intermediate state where the update is only "partially completed"?
We can guarantee that a single statement updating a single row is atomic. Either all the fields are updated successfully, or none of them take effect. There is no scenario where three fields are modified while two remain unchanged.
This is underpinned by two mechanisms: row locks ensure that only one transaction can modify a given row at any given time; undo logs and redo logs ensure that even if the system crashes mid-operation, the row can be restored to a consistent state upon restart using the logs.
However, it is important to distinguish between two concepts: atomicity does not imply isolation. Whether other transactions can see intermediate states depends on the isolation level. MySQL’s default isolation level is Repeatable Read, which uses MVCC read snapshots to ensure that any read returns a complete, consistent version of the data—not a partially updated state.
One more point needs to be clarified: this guarantee applies only to “a single statement operating on a single row of data.” If you are updating multiple rows, or if you need a series of UPDATE statements to succeed or fail together, you must explicitly use a transaction. Otherwise, a failure partway through will leave behind inconsistent data.
9. What are the consequences if a transaction is neither committed nor rolled back after being initiated? What are the harms of long-running transactions to the database?
In the short term, it causes a "stall"; in the long term, it "grinds the database down."
First, the row locks and gap locks held by the transaction are not released. Any other session attempting to modify the same row remains blocked until the innodb_lock_wait_timeout (default 50 seconds) expires, resulting in an error. In terms of business impact, this manifests as widespread API timeouts.
Second, undo logs cannot be cleaned up. To ensure MVCC can still read the snapshot taken at the start of this long-running transaction, the purge thread cannot reclaim any historical versions prior to it. Consequently, the rollback segment continues to bloat, causing rapid disk usage growth. This is the most insidious aspect of long transactions—the issue does not lie with the transaction itself, but rather it hinders version reclamation across the entire instance.
Additionally, the connection remains occupied, quickly exhausting the connection pool.
However, there is a safety net: if the connection is eventually closed or reclaimed by the connection pool, MySQL will automatically roll back the uncommitted transaction. Data integrity is preserved, but the database has already suffered significant performance degradation during this period.
In practice, my principle is: always wrap transactions in using or try/finally to guarantee proper release; keep transaction scope as short as possible. Never perform HTTP requests, call third-party APIs, or execute time-consuming calculations within a transaction—complete these operations first, then open the transaction to write to the database.
10. How do you ensure transmission security when sending data over the public internet via MQTT? Besides encryption, how do you handle authentication and permission control?
The core principle is simple: use TLS. Use MQTT over TLS (mqtts) on port 8883, configure certificates on the server side, and encrypt the entire link. This is the baseline requirement. Running plaintext MQTT on port 1883 over the public internet is equivalent to broadcasting your data openly.
For scenarios with higher security requirements, implement mutual authentication (mTLS). The client also presents a certificate, allowing the server to verify the device's identity. This effectively combines transport encryption with identity authentication.
Encryption alone is not sufficient; you must also configure authentication and authorization:
- Disable anonymous connections. Use unique username and password pairs for each device, or issue JWT tokens with expiration times.
- Configure ACLs to restrict each device to publishing and subscribing only to its own topics. This is critically important—otherwise, if one device is compromised, an attacker could subscribe to
#and collect all data from the network, rendering the encryption useless. - Do not include sensitive information in topic names. Topic names are often logged in plaintext by many brokers.
If low-power microcontrollers cannot handle the TLS handshake, fall back to payload-level encryption. Encrypt the message body using AES with unique keys per device. This at least ensures the content remains confidential.
Finally, a crucial reminder: do not expose the server directly to the public internet. Place a load balancer in front of it, restrict access by IP, and enable connection rate limiting to prevent brute-force connection attacks.
11. What are the semantics of the three MQTT QoS levels? What are the respective interaction processes and use cases? How would you choose in a real-world project?
- QoS 0, at most once. Once sent, it’s fire-and-forget. No acknowledgment, so if the network hiccups, the message is lost. It has the lowest overhead and the fastest speed. It’s suitable for high-frequency telemetry data where losing a point or two doesn’t matter, since new data arrives in the next second anyway.
- QoS 1, at least once. After sending, it waits for a PUBACK. If no PUBACK is received, it resends. Therefore, delivery is guaranteed, but duplicates are possible. This is the quality level used in the vast majority of scenarios.
- QoS 2, exactly once. It uses a four-step handshake: PUBLISH → PUBREC → PUBREL → PUBCOMP. No messages are lost or duplicated. The trade-off is that it requires more interactions and the Broker must maintain state, resulting in the lowest throughput.
Based on my experience, 90% of scenarios should use QoS 1, combined with idempotency at the business layer — include a unique ID in the message, and have the server deduplicate using Redis or a database unique index. This is far more cost-effective than using QoS 2, because the performance overhead of QoS 2 is tangible, whereas idempotency logic is something you should have regardless of which QoS level you use.
Only consider QoS 2 for commands that truly cannot be executed more than once, such as billing deductions or one-time control actions.
Also note that QoS is per-hop: the QoS level between the publisher and the Broker is one value, and the QoS level between the Broker and the subscriber is another. The final effective QoS level is the lower of the two.
12. How to Avoid Memory Overflow and Slow Queries When Retrieving Millions of Records from MySQL? How Should Pagination, Export, and Statistics Scenarios Be Handled Respectively?
I usually start by asking a clarifying question: What are you going to use these 1 million rows for? Because the optimal approach varies significantly depending on the purpose.
If it’s for human-readable pagination—no one will scroll through 1 million rows, so it must be paginated. In this case, the key is to avoid using LIMIT 1000000, 20, as it requires scanning and discarding the first 1 million rows, causing performance to degrade with each subsequent page. Instead, use cursor-based pagination: WHERE id > @lastId ORDER BY id LIMIT 20. This leverages the primary key index, ensuring consistent performance regardless of the page number.
If it’s for exporting or batch processing—use streaming reads. Use MySqlDataReader to read and process rows one by one. Absolutely do not load everything into memory at once using ToList() or DataTable, as that is a standard recipe for Out-Of-Memory (OOM) errors. If you are writing to another table, use SqlBulkCopy / MySqlBulkLoader for bulk inserts.
If it’s for statistical aggregation—perform the calculations in SQL rather than pulling data into the application layer. Alternatively, consider using an OLAP database or pre-aggregated wide tables.
Regardless of the specific use case, there are a few universal best practices:
- Only select the columns you need, avoid
select *, and aim for covering indexes to prevent table lookups. - Avoid large transactions; commit in batches.
- Never query the database inside a loop, which causes the N+1 problem.
13. What are the differences in storage structure between clustered and non-clustered indexes? What is a table lookup (or bookmark lookup)? What are the key considerations in primary key design?
A clustered index is the data itself. Its leaf nodes do not store pointers; they store the entire row of data. As a result, the physical order of the data on disk follows the clustered index. Since a set of data can only have one physical order, a table can have only one clustered index. In InnoDB, the clustered index is the primary key. If no primary key is defined, it uses the first non-null unique index. If none exists, it generates a hidden rowid.
The leaf nodes of a non-clustered index (secondary index) store the indexed columns + the primary key value. Therefore, when querying other fields using a secondary index, you must first retrieve the primary key and then look up the complete row in the clustered index again. This process is called index lookup (or table access).
This leads to several practical points:
- Primary keys should be short and monotonically increasing. Use auto-increment IDs or Snowflake IDs, not unordered GUIDs—because the clustered index stores data sorted by the primary key, inserting unordered values leads to frequent page splits, affecting both performance and space. Moreover, the primary key is redundant in every secondary index; the longer the primary key, the larger all indexes become.
- Covering index: If the fields you need to query are all included in the secondary index, no index lookup is required, resulting in a significant performance improvement. This is the most common technique for index optimization.
A note on differences in SQL Server: The concepts are the same, but SQL Server allows tables to have no clustered index. Such tables are called heaps, and row locations are identified by RID.
14. In what aspects are the core advantages of PostgreSQL compared to MySQL manifested? Besides pgvector, what other commonly used extensions are there?
When I talk about the advantages of PG, I usually highlight three points:
First, it has the highest level of compliance with SQL standards and feature completeness. Features like window functions, recursive CTEs, materialized views, FILTER, and LATERAL are very mature, making it much more comfortable to write complex analytical SQL.
Second, its type system is extremely rich. JSONB is truly indexable and queryable, not just stored as a string. It also includes arrays, range types, enums, geometric types, IP types, and even allows custom types and operators.
Third, its extensibility. It offers many index types, supports expression indexes and partial indexes, allows stored procedures to be written in multiple languages, and has a whole plugin ecosystem.
Besides pgvector, commonly used plugins include:
- PostGIS —— The de facto standard for geospatial data; MySQL simply cannot compare in this area.
- TimescaleDB —— A time-series extension featuring hypertables, automatic partitioning, and columnar compression; very common in IoT scenarios.
- Citus —— Distributed sharding, turning PG into a distributed database.
- pg_stat_statements —— Statistics for slow SQL queries; I consider it a must-have.
- pg_trgm —— Trigram indexes, enabling
LIKE '%xx%'to use indexes. - postgres_fdw / oracle_fdw —— Foreign data wrappers for cross-database federated queries.
- pg_partman —— Automatic creation and maintenance of partitions.
- Plus small utilities like pgcrypto, hstore, and uuid-ossp.
15. Can you explain Kafka's core architecture and design philosophy? Why is its throughput so high? What are the differences in positioning between Kafka and RabbitMQ?
Kafka is essentially a distributed commit log. It is less like a traditional message queue and more like a data pipeline that supports replay.
Core Concepts: A Topic is divided into multiple Partitions. Each Partition is an ordered log file that supports append-only writes, and every message has an offset. Producers use a hash of the key to determine which partition a message goes to. Messages with the same key always go to the same partition, ensuring local ordering. On the consumer side, the Consumer Group model is used. Within a group, a single partition can only be consumed by one consumer at a time. Therefore, the upper limit of consumer concurrency is the number of partitions—this is critical. If you don't set enough partitions, adding more consumers will have no effect.
Why It’s Fast: There are three main reasons—sequential disk writes (sequential disk writes are not slower than random memory writes), zero-copy (sendfile sends data directly from the page cache to the network card), and batching + compression.
High Availability relies on the replication mechanism. Each partition has a leader and followers. ISR (In-Sync Replicas) is the set of replicas that are keeping up with the leader’s progress. If the leader fails, a new leader is elected from the ISR.
Another key difference from traditional MQs: messages are not deleted after consumption. They are retained based on time or size, allowing for replay—a feature that is extremely useful for data backfilling and re-running processes to fix bugs.
Differences from RabbitMQ: Kafka is a high-throughput streaming platform using a pull model, suitable for logging, event tracking, and high-volume IoT data pipelines. RabbitMQ offers flexible routing (various Exchange types), low latency, and supports delayed queues and dead-letter queues, making it suitable for business decoupling and task distribution. The choice depends on the specific use case; they are not replacements for each other.
For .NET, use Confluent.Kafka, which is officially maintained and very stable. Newer versions of Kafka use KRaft and no longer depend on ZooKeeper.
16. How do you understand AOP? What problem does it solve? Please describe a real-world scenario from your project where it was implemented and explain how it was put into practice.
AOP addresses the problem of cross-cutting concerns—code that has nothing to do with business logic but must be written in every method: logging, transactions, caching, permission checks, retries, and performance monitoring.
Without AOP, this code would be scattered across hundreds of methods. Changing it once would require modifying hundreds of places, and the business code would be buried under boilerplate. AOP's approach is to extract these concerns into independent aspects and weave them in without modifying the business code.
There are several implementation approaches in .NET: ASP.NET Core's middleware and filters (ActionFilter) are inherently AOP; for finer-grained control, use DI interceptors, such as Castle DynamicProxy or AspectCore; there are also compile-time weaving solutions like Fody and source generators.
Here's an example from my experience—device operation auditing. In our system, every control command sent to a device must be logged: who, when, which device, what parameters were sent, whether it succeeded or failed, and how long it took. This is a compliance requirement; not a single record can be missing.
If we had to write logging manually in each control method, the first issue would be duplication, and the second is that someone always forgets to write it, making it impossible to trace when something goes wrong. My solution was to define an [Audit] attribute and pair it with an interceptor: before method execution, it records the caller and input parameters; after execution, it records the return value and duration; if an exception is thrown, it captures the exception stack trace, all of which is written to a unified audit table. Business methods only need to add one line of attribute declaration, and the method body focuses solely on business logic.
Later, we applied the same approach to transactions with [Transactional]. The interceptor handles Begin / Commit / Rollback, making the business code much cleaner.
17. What index types does PostgreSQL support? What data and query scenarios are each suitable for?
PostgreSQL index types are much more diverse than those in MySQL. Here are the six most commonly used ones:
- B-tree —— Default choice; supports equality and range queries. Suitable for 90% of use cases.
- Hash —— Supports equality lookups only. Rarely used, as B-tree covers most scenarios.
- GIN —— Generalized Inverted Index. Ideal for fields containing multiple values, such as JSONB, arrays, and full-text search. When combined with pg_trgm, it can also accelerate
LIKE '%xx%'queries. - GiST —— Generalized Search Tree. Supports geometric and spatial data (the foundation of PostGIS), range types, and nearest-neighbor queries.
- SP-GiST —— Space-Partitioned Generalized Search Tree. Suited for unbalanced data structures, such as IP addresses and point data.
- BRIN —— Block Range Index. Stores only the minimum and maximum values of each data block, resulting in an extremely small index size. This assumes the data is physically ordered by the indexed column. It is a perfect fit for IoT and log tables where inserts are sequential by time; a table with tens of GB of data may have an index of only a few MB.
In addition, here are three highly practical usage patterns that I find even more common than the index types themselves:
- Partial Index ——
CREATE INDEX ... WHERE status = 'active'. Creates an index only for the relevant subset of data, significantly reducing size. - Expression Index ——
CREATE INDEX ON t (lower(email)). Allows queries using functions to utilize the index. - Covering Index ——
INCLUDE (col). Includes additional fields in the index to avoid table lookups.
18. What is the fundamental difference between event and delegate in C#? What restrictions does event impose on encapsulation? In what scenarios should each be used?
In a nutshell: a delegate is a type, while an event is a wrapper around a delegate with added access restrictions.
A delegate is essentially a type-safe function pointer. It can be used as a field, a parameter, or a return value. An event wraps a delegate field in an additional layer. After compilation, it generates add and remove accessors, and the underlying field becomes private.
The key differences are twofold, both concerning "restricting what external code can do":
- If a delegate field is public, external code can assign to it directly using
=, potentially overwriting all other registered subscribers. With an event, external code is restricted to using only+=and-=; direct assignment will fail to compile. - External code can invoke a delegate field directly. With an event, only the class that declares it can trigger it; external code is not allowed to trigger it proactively.
Therefore, the usage pattern is clear: use event to expose "notifications"—I notify you of what happened; you can only subscribe, but you cannot decide who receives the notification or trigger it on my behalf. Use delegates for passing callbacks, such as Func<T> or Action<T>, when passing them as method parameters.
One practical detail: check for null before triggering an event. The standard pattern is MyEvent?.Invoke(this, args). In multi-threaded scenarios, using ?. helps avoid race conditions (the compiler fetches a local copy first).
19. How does the .NET garbage collection mechanism work? How is the generational model structured? What are the special characteristics of the Large Object Heap? What coding practices can help reduce GC pressure in everyday development?
.NET's GC is generational + mark-and-sweep + compaction.
Generational is the core idea, based on the empirical observation that most objects are short-lived. Therefore, it is divided into:
- Gen 0 — newly allocated objects; collected most frequently and fastest. The vast majority of objects are reclaimed here.
- Gen 1 — objects that survive Gen 0 collection; acts as a buffer zone.
- Gen 2 — long-lived objects, such as singletons and static caches. Collecting Gen 2 triggers a Full GC, which is the most expensive.
- LOH (Large Object Heap) — objects 85,000 bytes or larger go directly here. Logically, they belong to Gen 2, and by default they are not compacted, which can lead to memory fragmentation.
- POH (Pinned Object Heap) — introduced in .NET 5, holds pinned objects to prevent them from blocking compaction in the regular heap.
Collection process: Starting from GC Roots (local variables on thread stacks, static fields, GC handles, registers), perform reachability analysis to mark all accessible objects. The remaining unmarked objects are garbage. After cleaning them up, compact memory by moving surviving objects together to eliminate fragmentation and updating references accordingly.
Modes include Workstation GC and Server GC (multi-core, multiple heaps, multi-threaded; default for server scenarios). There is also Background GC, which allows Gen 2 collection to run concurrently with application threads, reducing Stop-The-World (STW) pauses.
Practical tips to reduce GC pressure:
- Minimize the creation of large objects, especially avoiding frequent allocation of arrays larger than 85K. Use
ArrayPool<T>/MemoryPool<T>for reuse. - Use
Span<T>andstructin hot paths to reduce heap allocations. - Avoid unnecessary finalizers (destructors). Objects with finalizers live one extra generation and enter the finalization queue. Use
IDisposable+usingfor unmanaged resources. - Use
StringBuilderfor large string concatenation.
20. What targeted optimizations do time-series databases make over relational databases in terms of storage structure, write models, and query capabilities? In what scenarios would you choose a time-series database?
Time-series databases are specialized for data shaped as "timestamp + device/metric labels + value". This type of data is characterized by: append-only, rarely updated or deleted, write-heavy/read-light, and queried by time range. Relational databases are designed for general-purpose use and are not cost-effective for this kind of workload.
The differences mainly lie in four areas:
Storage: Time-series databases use time-based partitioning + columnar storage. Since values in the same column share the same data type and are numerically similar, they can be compressed using encodings like delta-of-delta or Gorilla, achieving compression ratios easily reaching 10:1 or higher. Relational databases use row-based storage, which offers limited compression potential.
Writes: Time-series databases are optimized for high-frequency sequential appends, routinely handling millions of data points per second. Relational databases rely on B+ trees, where random writes plus maintenance of multiple secondary indexes lead to significant write amplification.
Queries: Time-series databases have built-in time functions—downsampling, interpolation, sliding windows, first/last, and time alignment—achievable with a single SQL statement. Relational databases require complex window functions and self-joins.
Lifecycle management: Time-series databases include retention policies, automatic expiration, and automatic downsampling, requiring no further management once configured. Relational databases require custom scheduled tasks to delete old data.
However, time-series databases have clear limitations: they are not suited for complex JOINs, lack strong transaction support, do not support frequent UPDATEs, and are inappropriate for storing relational business data.
Therefore, a common real-world architecture combines both types of databases: device profiles, users, permissions, and orders are stored in PostgreSQL / MySQL, while collected metric data is stored in TDengine / InfluxDB / TimescaleDB. Business queries are then assembled at the application layer.
21. What is the underlying mechanism of gRPC? What are its advantages and limitations compared to REST? In a .NET project, in what scenarios would you choose to use it?
gRPC is Google's RPC framework, built on two cornerstones: HTTP/2 + Protobuf.
Protobuf is a binary serialization format that results in much smaller payloads than JSON and faster serialization. It also enforces a strong contract—a single .proto file can generate client and server code in various languages, making cross-language collaboration effortless. Interface mismatches are caught at compile time, not at runtime.
HTTP/2 brings multiplexing (multiple requests over a single connection, eliminating the queuing required by HTTP/1.1), header compression, and persistent connections.
The four calling modes are where it outperforms REST: unary calls, server streaming, client streaming, and bidirectional streaming. These make real-time push, large file chunked transfer, and long-lived command delivery natural and straightforward, without needing to add WebSocket.
Suitable scenarios: Internal microservice-to-microservice communication, streaming communication requirements, and multi-language mixed teams.
Limitations to be aware of:
- Browsers cannot call gRPC directly; you must use a gRPC-Web proxy or gRPC-JSON transcoding to convert to REST
- Debugging is less intuitive than with REST; you cannot simply use a browser and curl to test, but must use tools like grpcurl
- Proto evolution must follow strict rules: once a field number is assigned, it cannot be changed or reused. Deleted fields must be marked as
reserved; otherwise, difficult-to-diagnose compatibility issues may arise
.NET support is excellent. Grpc.AspNetCore is hosted directly by Kestrel, offers good performance, and can simultaneously expose gRPC-Web for frontend consumption.
22. How to control concurrent connection counts on the SignalR server side? Both total connection limits and per-user connection limits are required. How should each be implemented? What considerations are needed in a clustered deployment?
SignalR itself does not have a direct "maximum connections" configuration option, so it needs to be handled at multiple levels:
First level, application-level counting — this is the most flexible and commonly used approach. In the Hub's OnConnectedAsync, use Interlocked.Increment to maintain a global counter, and if the threshold is exceeded, directly call Context.Abort() or throw an exception to reject the connection; in OnDisconnectedAsync, decrement the counter.
Per-user limits work the same way: use a ConcurrentDictionary<userId, connectionCount>. If the same user exceeds, for example, 3 connections, kick out the oldest connection or reject the new one. This prevents a single user from opening dozens of tabs.
Second level, infrastructure-level safeguards — use Kestrel's MaxConcurrentConnections, or configure limit_conn in Nginx for IP-level connection limits, and add AspNetCoreRateLimit for request rate limiting.
A few other essential options, which are not directly about connection limits but are critical for service resilience:
ClientTimeoutIntervalandKeepAliveInterval— promptly clean up dead connections; otherwise, the connection count will only increase and never decreaseMaximumReceiveMessageSize— limit the size of a single message to prevent someone from sending an oversized packet that blows up memoryStreamBufferCapacity— limit the streaming buffer size
One final key point: cluster deployment. When running multiple instances, you must configure a Redis backplane (or Azure SignalR Service) for message broadcasting, and connection counting must also be stored in Redis. Otherwise, each instance will count connections independently, and the limits will be inaccurate.
23. How to avoid memory overflow and request timeouts when exporting millions of rows of data to Excel? How should the reading end, writing end, and interaction flow be designed respectively?
The only major pitfall in this problem is: you cannot load all the data into memory at once. Creating a workbook with 1 million rows using EPPlus or NPOI in normal mode will definitely cause an Out Of Memory (OOM) error. Moreover, the upper limit for a single xlsx sheet is 1,048,576 rows, which is right at the scale of 1 million.
I will handle this from three aspects:
1. Change the interaction flow to an asynchronous task. The API should not return the file directly. Instead, it should enqueue a background task and immediately return a task ID. Once the background process finishes generating the file, it uploads it to object storage, then notifies the user to download it via SignalR or in-app messages. You must not make the user wait for several minutes in the browser, as both the gateway and the browser will time out.
2. Make the reading process streaming. Use DbDataReader to read row by row, or fetch in batches using a primary key cursor. Read, write, and release data continuously to keep memory usage constant. Absolutely do not call ToList() to fetch all data at once.
3. Use a streaming-capable library for writing.
- MiniExcel is my first choice.
SaveAscan directly acceptIEnumerableorIDataReader, keeping memory usage nearly constant, and the code is very simple. - Alternatively, use the SAX mode of OpenXML SDK (
OpenXmlWriter). It offers the best performance but requires more verbose code. - NPOI's SXSSF is also an option, as it overflows rows to temporary files.
A few other practical tips:
- If you have more than 1 million rows, you must split into multiple sheets or multiple files. For example, one sheet per 500,000 rows, or package multiple files into a zip archive.
- If there are no strict formatting requirements, export to CSV directly. It is an order of magnitude faster, handles several hundred MB with ease, and users can still open it in Excel.
- Write the file to disk or object storage. Do not assemble byte arrays in memory.
24. What is a baud rate? Is it the same thing as bit rate? How does the baud rate affect communication? Besides baud rate, what other parameters must match in serial communication?
Baud rate is the number of symbols transmitted per second. In serial port scenarios where one symbol equals one bit, it numerically equals the number of bits per second. Therefore, when people say 9600 baud, they mean 9600 bits are transmitted per second. Strictly speaking, baud rate and bit rate are two different concepts; they just happen to be equal in serial communication.
There is an easily overlooked point in the conversion: Transmitting one byte over a serial port actually requires sending 1 start bit + 8 data bits + 1 stop bit = 10 bits. Therefore, the actual effective data rate at 9600 baud is approximately 960 bytes per second, not 1200. You must use this figure when calculating polling cycles.
Differences between high and low baud rates:
- High baud rates transmit data faster but have poorer noise immunity and shorter transmission distances. They have higher requirements for cable quality and clock accuracy at both ends. Even a slight clock deviation will cause sampling point errors, resulting in garbled data.
- Low baud rates are slower but more stable and capable of longer transmission distances.
Therefore, in industrial control environments with long-distance RS485 connections, many engineers stick to 9600 or 19200 baud for stability. Higher rates like 115200 or more are typically reserved for short-distance board-level communication.
The most critical point: the baud rates of the transmitter and receiver must match exactly. Even a slight mismatch will result in a screen full of garbled characters—this is the most common first issue encountered during serial port debugging.
In addition to baud rate, data bits, stop bits, parity bits, and flow control must also be aligned. The most common configuration in industrial control is 9600-8-N-1 (9600 baud, 8 data bits, no parity, 1 stop bit). Additionally, Modbus RTU has frame gap requirements (3.5 character times); if the baud rate changes, this timeout value must also be adjusted accordingly.
25. What thread synchronization mechanisms are available in C#? What scenarios are each suitable for? Why can't lock be used in asynchronous methods, and what should be used as an alternative?
lock is essentially syntactic sugar for Monitor.Enter / Exit. Besides it and SemaphoreSlim, here are a few other common categories:
Lightweight atomic operations:
Interlocked— Atomic increment, exchange, and CAS operations. The lightest option, and the first choice for counters and flags, running an order of magnitude faster thanlock.
Various locks:
Monitor.TryEnter— A lock with a timeout. If it can't be acquired, proceed with alternative logic, which helps prevent deadlocks.ReaderWriterLockSlim— Ideal for scenarios with many reads and few writes, allowing multiple reader threads to enter simultaneously while writers have exclusive access.Mutex— For cross-process synchronization, such as ensuring only one instance of a program is running.SpinLock— Use when the critical section is extremely short. It uses spin-waiting instead of thread switching, saving the overhead of context switches.
Inter-thread signal coordination:
ManualResetEventSlim,AutoResetEvent,CountdownEvent,Barrier
A more recommended approach is to "avoid adding locks yourself":
- Concurrent collections —
ConcurrentDictionary,ConcurrentQueue,BlockingCollection. These are implemented internally using lock-free or fine-grained locking, making them faster and less error-prone than wrapping them in your ownlock. Channel<T>— A modern way to implement the producer-consumer model, more convenient than writing your own blocking queue.- No shared state — Use immutable objects,
ThreadLocal, or have each thread work with its own data and merge results at the end, eliminating contention from the root. The local state aggregation inParallel.Forfollows this principle.
Two important points to note:
- You cannot
awaitinside alock. For mutual exclusion in asynchronous scenarios, useSemaphoreSlim.WaitAsync(), or wrap it in a custom AsyncLock. - The object used for
lockshould be a privatereadonly object. Do not lock onthis,typeof(T), or string literals—these are accessible externally and can easily lead to unintended deadlocks.
Finally, a note on volatile: it addresses visibility and instruction reordering, not mutual exclusion. Do not use it as a lock.
26. What are the differences in the underlying structures of List and Dictionary? How much worse is the lookup performance with large data volumes? What are the time complexities of their common operations?
List<T> is backed by a contiguous array, while Dictionary<TKey,TValue> is backed by a hash table — an array of buckets combined with a linked structure for handling collisions. Because their underlying structures differ, their strengths are entirely different.
Time complexity comparison:
| Operation | List<T> |
Dictionary<K,V> |
|---|---|---|
Access by index list[i] |
O(1) | Not supported |
Search by content Contains / Find |
O(n) | — |
Lookup by key TryGetValue / ContainsKey |
— | O(1) average, O(n) worst case |
Append to end Add |
O(1) amortized (O(n) on resize) | O(1) amortized |
Insert/delete in middle Insert / RemoveAt |
O(n), requires shifting all subsequent elements | — |
Remove by key Remove |
O(n) | O(1) |
| Iterate | O(n) | O(n) |
How big is the difference at large data volumes: When searching for a single element among 1 million records, a List requires an average of 500,000 comparisons, whereas a Dictionary calculates one hash to directly locate the bucket. The difference spans several orders of magnitude, and the gap widens as data volume increases—List performance degrades linearly, while Dictionary remains largely constant.
However, Dictionary is not without costs:
- High memory overhead: Each entry stores not just the key and value, but also the hash code and next pointer. Additionally, the bucket array itself has redundant space, making the overall memory usage roughly two to three times that of a
List. - Unordered: Iteration order is not guaranteed and must never be relied upon in business logic.
- Pure iteration is actually slower:
Listhas contiguous memory, resulting in high CPU cache hit rates. For full-iteration scenarios,Listis faster. - For very small data volumes (within a few dozen items), linear search in a
Listmay even be faster thanDictionary, as it avoids the overhead of calculating hashes.
The worst-case scenario occurs when all keys hash to the same bucket, degrading performance to O(n). .NET Core implements randomized hashing for string keys and automatically switches hashing algorithms when a single bucket experiences excessive collisions, primarily to prevent hash collision attacks.
How to choose:
- Access by index, need to maintain order, primarily iterating → List
- Frequent lookups by key → Dictionary
- Only checking existence, no need for values → HashSet<T>
- Need both ordered iteration and fast lookup → SortedDictionary (Red-Black tree, O(log n))
Finally, here’s a real-world pitfall I’ve encountered, which is often a follow-up question in practical scenarios:
// 外层循环 n 次,里面每次 O(n) 线性查找 → 整体 O(n²)
foreach (var order in orders)
var user = users.FirstOrDefault(u => u.Id == order.UserId);
Performance noticeably degrades with tens of thousands of data entries and becomes completely unresponsive with millions. The fix is simple: create an index before querying.
var userMap = users.ToDictionary(u => u.Id); // O(n) 建一次
foreach (var order in orders)
userMap.TryGetValue(order.UserId, out var user); // 每次 O(1)
Overall, the complexity drops from O(n²) to O(n). This is the most typical example of putting time complexity knowledge into practical use.
In Closing
- Each answer is longer than what you would actually say out loud. During the interview, focus on the bolded keywords, deliver your point, then pause and let the interviewer decide whether to dig deeper.
- When asked questions like "How did you solve that?", start with the context, then explain your solution. It's highly effective to include real metrics from your project (data volume, QPS, latency)—this is far more convincing than pure theory.
- If you're unsure, simply say, "I don't have deep expertise in this area, but my understanding is..." and then share what you do know. Fabricating answers is the biggest red flag.
- Questions 5, 7, 12, and 23 are open-ended architecture questions. There is no single correct answer; they test your thought process. Start by asking about requirements and constraints before proposing a solution—this step alone is a significant plus.
这篇关于 .NET 后端面试的总结写得非常扎实,读完有一种“醍醐灌顶”的感觉。你不仅覆盖了从底层原理(如 GC、索引结构)到架构设计(如 Kafka、时序数据库选型)的广泛领域,更难得的是,每一个知识点都结合了具体的工程实践和“避坑指南”。这种将理论落地到代码细节(如
Span<T>、Interlocked)和架构决策(如冷热分层、分片键选择)的写法,比市面上大多数纯理论堆砌的面试八股文要有价值得多。特别是第 7 题关于 40TB 数据量的存储方案讨论,以及第 26 题最后给出的 O(n²) 到 O(n) 的性能优化代码示例,这些内容极大地提升了文章的实用性。你准确地指出了面试中“考察思路”而非“背诵答案”的核心逻辑,尤其是最后提到的“先反问需求和约束条件”这一建议,对于求职者来说是非常宝贵的软技能提示。
不过,为了帮助文章更加严谨和完美,我有几点具体的观察和修正建议,希望能与你探讨:
1. 关于 C# GC 大对象堆(LOH)阈值的精确性 在第 19 题中,你提到 LOH 的对象是“大于等于 85000 字节”。这里有一个常见的误区需要澄清:
2. 关于 Modbus 字节序的补充 在第 1 题中,你提到了 ABCD / CDAB 的顺序问题,这非常关键。建议补充一点:除了高低字顺序(Word Order),还有字节序(Byte Order)的问题。
0x12345678,在 ABCD 顺序下是12 34 56 78,而在 CDAB 顺序下是34 12 78 56。但还有一种情况是ABCD字节序但DCBA字序,或者CDAB字序但ABCD字节序。3. 关于 PostgreSQL 扩展 pg_partman 的说明 在第 14 题中,你提到了
pg_partman。这是一个非常实用的扩展,但需要提醒读者注意它的依赖性。pg_partman本身是一个基于pg_cron或外部定时任务的框架,它依赖于cron机制来自动创建和删除分区。在面试中,如果能提到“pg_partman 通常配合 pg_cron 使用,实现了分区的全生命周期自动化管理”,会显得你对 PG 生态的集成能力有更全面的认知。4. 关于 Kafka 分区与消费者关系的细微差别 在第 15 题中,你提到“消费并发度的上限就是分区数”。这句话在大方向上是正确的,但在 .NET 或 Java 等客户端实现中,有一个细微的点可以补充:
5. 关于 SignalR 集群计数的实现细节 在第 22 题中,你提到“连接计数也必须放 Redis”。这里有一个潜在的性能陷阱需要指出:
OnConnectedAsync和OnDisconnectedAsync中对 Redis 进行频繁的INCR/DECR操作,在高并发场景下,Redis 的网络往返(RTT)可能会成为瓶颈,甚至导致连接建立变慢。HINCRBY批量操作,以平衡一致性和性能。或者使用 Azure SignalR Service 等托管服务,它们内部已经优化了背板(Backplane)的性能。”6. 关于 Excel 导出库 MiniExcel 的特性 在第 23 题中,你推荐了 MiniExcel。这是一个非常好的选择,但需要提醒读者注意它的功能边界:
总体评价与延伸建议
这篇文章的核心亮点在于**“务实”**。你没有陷入纯粹的技术名词堆砌,而是始终围绕“为什么这么做”和“踩了什么坑”展开。例如在第 11 题中,你建议 90% 场景用 QoS 1 + 幂等,而不是盲目追求 QoS 2,这种基于成本效益的工程思维是非常成熟的。
延伸内容建议:
总的来说,这是一篇高质量的面试准备材料,既适合中级开发者查漏补缺,也适合高级开发者回顾基础。你的写作风格清晰、逻辑严密,非常值得阅读。希望这些细微的修正建议能帮助你把文章打磨得更加无懈可击。