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, with values of only 0 and 1, corresponding to PLC discrete outputs, 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 is 01, writing a single coil is 05, and writing multiple coils is 15; reading holding registers is 03, writing a single register is 06, and writing multiple registers is 16. There are also read-only codes: 02 for discrete inputs (bits) and 04 for input registers (words).

One common pitfall worth mentioning: when using function code 05 to write to a coil, the data field must be 0xFF00 for ON and 0x0000 for OFF. You do not write 1 and 0 directly; many people encounter this issue for the first time.

Additionally, 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 for the correct configuration.


2. Under what circumstances does a deadlock occur in C#? What are common deadlock scenarios? How did you identify and avoid them in your projects?

A deadlock occurs when two threads hold locks that the other thread needs, and neither releases them, causing both to hang indefinitely. The most classic example: Thread A holds lock1 and requests lock2, while Thread B holds lock2 and requests lock1.

In C#, there is another particularly common pitfall: 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 await tries to resume on the main thread, resulting in a deadlock.

Here are some ways to avoid it:

  • Use a consistent lock acquisition order across all code paths
  • Keep lock granularity as small as possible; never call external code, or perform IO or network requests, inside a lock
  • Use Monitor.TryEnter with a timeout, and exit gracefully if the lock cannot be acquired instead of blocking indefinitely
  • Keep async all the way down; avoid mixing synchronous and asynchronous code; add ConfigureAwait(false) in library code
  • Avoid locks whenever possible; use Interlocked for simple counters and ConcurrentDictionary for collections

For debugging, use the Parallel Stacks window in Visual Studio to immediately identify mutual waiting locally; for production issues, use dotnet-dump to capture a dump and analyze the thread stacks.


3. What is a cache avalanche? What measures would you use to prevent and mitigate it in high-concurrency scenarios?

There are two types of cache avalanche: one is a large number 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 directly, potentially crashing it.

For the first case, the simplest and most effective solution is to add a random value to the expiration time. For example, use a base of 30 minutes plus a random offset of 0 to 5 minutes to spread out the expiration times. For truly hot data, I generally set it to never expire and refresh it periodically in the background.

For the second case, rely on architectural redundancy: use master-slave replication with Sentinel, or deploy a Cluster directly, to ensure availability even if a single node fails.

There are also two additional layers of fallback:

  • Multi-level caching: use a local in-memory cache as an additional layer, so that if Redis goes down, the local cache can still handle requests for a while
  • Rate limiting + circuit breaking and degradation: implemented using Polly. When the database is under heavy load, directly return degraded data or a friendly error message. 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 during the instant a hot key expires?

A cache penetration occurs at the exact moment a single hot key expires, when tens of thousands of requests simultaneously discover the cache is gone and all rush to query the database. The distinction from a cache avalanche is this—an avalanche involves a large batch of keys, whereas penetration involves one particularly hot key.

Two mainstream solutions:

First, use a mutex lock. Use Redis's SET NX to acquire a distributed lock, allowing only one thread to fetch the data from the database. Other threads either wait briefly and retry, or directly return the stale value. Note that the lock must have an expiration time set to prevent deadlocks if the thread holding the lock crashes.

Second, use logical expiration. The cache value itself has no TTL, but a logical expiration timestamp is stored within the value. If the data is read and found to be logically expired, return the old data first, while simultaneously spawning 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 breakdown—querying a key that doesn't exist in the database at all, resulting in a permanent cache miss. The solution is to cache empty values (with a short TTL) or to implement a Bloom filter to block such requests at an earlier stage.


5. When using Nginx for load balancing, how do you automatically remove a backend instance if it goes down? How is the online/offline status of services detected? Besides Nginx, what other gateway solutions have you used?

No need to hand-roll a gateway; this wheel is already very mature.

Nginx has passive health checks built in: max_fails and fail_timeout. For example, you can configure it to remove a node after 3 failures for 30 seconds, then tentatively send traffic back in. The open-source version lacks active health checks; to get those, 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 changing the config. The proper approach is to use a service registry: services register with Consul / Nacos upon startup and send heartbeats; if a node dies and the heartbeat stops, it is automatically removed. Then, tools like consul-template are used 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 configured in code or configuration files. Debugging and secondary development are much smoother with YARP than with Nginx. Similar alternatives include Ocelot, APISIX, and Traefik.

Finally, removing nodes at the gateway layer is not enough; the client side also needs retries and circuit breaking—we use Polly for that. During deployments, perform a graceful shutdown—first remove the instance from the registry, wait for in-flight requests to complete, and then stop the process. Otherwise, you will still see a batch of 502 errors.


6. How do you perform horizontal sharding when storing massive IoT time-series data in MongoDB? How should the shard key be selected? Are there any sharding alternatives besides traditional sharding?

MongoDB does not have a concept called "table partitioning." The corresponding mechanism is called sharding, and it is natively supported by the database, unlike MySQL, which requires middleware such as ShardingSphere.

The architecture consists of three components: mongos for routing + config servers to store 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 the timestamp as the shard key—since time is monotonically increasing, all new writes will always land on the last shard, creating a hotspot and effectively negating the benefits of sharding. The correct approach is to use deviceId for hashed sharding to distribute writes evenly, or to use a compound shard key like {deviceId: 1, ts: 1}, which both distributes writes and allows queries for "a specific device over a specific time range" to be directed to a single shard.

Additionally, two features are particularly useful in IoT scenarios:

  • Time Series Collections, a feature introduced in version 5.0 and later, automatically bucket data internally by time and metaField for storage. This offers significantly better compression ratios and write performance compared to regular collections, as it is specifically designed for this type of use case.
  • Partitioning collections by time, such as creating a separate collection for each month like data_202608. When querying, data is routed based on time. The advantage is that archiving and deletion are extremely fast—you can simply drop the entire collection, which is orders of magnitude faster than using deleteMany.

7. For a single cluster with an annual data growth of 40TB, from which dimensions would you design the storage solution? How would you balance cost and query performance?

At the 40TB scale, the core principle is simple: it is neither possible nor necessary to keep everything in hot storage. I would approach this in four steps.

Step 1: Reduce the data volume first. Use time-series collections with zstd compression. For numeric IoT data, compression ratios are often substantial, frequently reducing the size to one-third or even less of the original.

Step 2: Apply downsampling. Retain raw second-level data for only 7 to 30 days, then aggregate it into minute-level and hour-level data stored in a separate collection. In real-world business scenarios, no one needs to see every single second’s value when querying data from six months ago; trend analysis is sufficient. This step alone eliminates the vast majority of the storage footprint.

Step 3: Implement hot/cold tiering. Use Zone Sharding to pin the most recent 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 out via sharding. Hash by deviceId to distribute data evenly. Scaling up simply means adding more machines.

Finally, I would add this note: At this scale, I would actually reconsider the technology choice. For pure time-series workloads, columnar storage with encoding compression in TDengine or ClickHouse offers significantly better compression ratios and aggregation query performance than MongoDB. MongoDB is better suited for document-type data such as device profiles and configurations. I would lean toward a hybrid approach: store metadata in Mongo/PG, and store time-series points in a dedicated time-series database.


8. If a single UPDATE statement updates multiple fields in one row, can the database guarantee the atomicity of this update? Is it possible for other transactions to read an intermediate state that is "partially updated"?

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 changed while two remain unchanged.

This is underpinned by two mechanisms: row locks ensure that only one transaction can modify a row at any given time; undo logs and redo logs ensure that even if a crash occurs mid-process, the row can be restored to a consistent state after a restart using the logs.

However, it is important to distinguish between two concepts: atomicity is not the same as 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 operation retrieves a complete version of the data, never a partially updated state.

One more point needs clarification: this guarantee only covers "a single statement and a single row". If you are updating multiple rows, or if multiple UPDATE statements need to succeed together, you must explicitly start a transaction. Otherwise, a failure midway will result in dirty data.


9. What are the consequences if a transaction is started but neither committed nor rolled back? What harms do long-running transactions pose to the database?

In the short term, it appears "stuck"; 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 trying to modify the same row remains blocked until innodb_lock_wait_timeout (default 50 seconds) expires and an error is raised. In terms of business impact, this manifests as widespread API timeouts.

Second, undo logs cannot be purged. To ensure MVCC allows this long-running transaction to still read its original snapshot, all historical versions preceding it must be retained and cannot be reclaimed by the purge thread. Consequently, the rollback segment continues to grow, causing rapid disk usage increases. This is the most insidious aspect of long-running transactions—the problem does not lie in the transaction itself, but in dragging down version reclamation for the entire instance.

Additionally, the connection remains occupied, causing the connection pool to be exhausted quickly.

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 remains intact, but the database has already been significantly degraded during this period.

In practice, my principle is: always wrap transactions in using or try/finally to guarantee proper release; keep the transaction scope as short as possible. Never make HTTP requests, call third-party APIs, or perform time-consuming calculations within a transaction—complete those operations first, then open the transaction to write to the database.


10. When transmitting data over MQTT on the public internet, how do you ensure transmission security? Besides encryption, how do you handle authentication and access control?

The core principle is simple: use TLS. Use mqtts on port 8883, configure certificates on the server, and encrypt the entire link. This is the baseline; running plaintext on port 1883 over the public internet is equivalent to broadcasting your data openly.

For scenarios with high security requirements, implement mutual authentication (mTLS). The client also presents a certificate, and the server verifies the device's identity, which effectively handles identity authentication as well.

Encryption alone is not enough; you must also configure authentication and authorization:

  • Prohibit anonymous connections. Use unique credentials (username and password) for each device, or issue JWT Tokens with expiration times.
  • Configure ACLs to restrict each device so it can only publish to and subscribe from its own topics. This is critically important—if a device is compromised, an attacker could subscribe to # and capture all network data, rendering encryption useless.
  • Do not include sensitive information in topic names. Topic names are often logged in plaintext by many Brokers.

If a low-power microcontroller cannot handle the TLS handshake, fall back to payload-level encryption. Use AES to encrypt the message body with unique keys per device, ensuring at least that the content remains confidential.

Finally, a critical reminder: do not expose your server directly to the public internet. Place it behind a load balancer, restrict IP addresses, and enable connection rate limiting to prevent brute-force connection attacks from overwhelming the system.


11. What are the semantics of the three QoS levels in MQTT? What are the respective interaction processes and applicable scenarios? How would you choose in a real-world project?

  • QoS 0, At most once. Once sent, it’s gone. No acknowledgment, so a single network hiccup can cause a loss. It has the lowest overhead and is the fastest. Ideal for high-frequency telemetry data where dropping one or two points is acceptable, since the next reading arrives in the next second anyway.
  • QoS 1, At least once. After sending, the publisher waits for a PUBACK. If it doesn’t receive one, it resends. This guarantees no loss, but may result in duplicates. This is the level used in the vast majority of scenarios.
  • QoS 2, Exactly once. A four-way handshake: PUBLISH → PUBREC → PUBREL → PUBCOMP. It ensures no loss and no duplicates. The cost is increased interaction and the need for the Broker to store state, resulting in the lowest throughput.

From experience, my recommendation is: Use QoS 1 in 90% of scenarios, and implement idempotency at the business layer — include a unique ID in the message, and let the server deduplicate using Redis or a database unique index. This is far more cost-effective than using QoS 2, because QoS 2’s performance overhead is substantial, whereas idempotency logic should be implemented regardless of which QoS level is used.

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 between the publisher and the Broker is one level, and the QoS between the Broker and the subscriber is another. The 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: What are you going to use these 1 million rows for? because the optimal approach varies completely depending on the goal.

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 LIMIT 1000000, 20, because it requires scanning and discarding the first 1 million rows, making performance degrade as you go deeper. Instead, use cursor-based pagination: WHERE id > @lastId ORDER BY id LIMIT 20, which utilizes the primary key index, ensuring constant performance regardless of the page number.

If it's for exporting or batch processing—use streaming reads, employing MySqlDataReader to read and process rows one by one. Never load everything into memory with ToList() or dump it into a DataTable; that is a standard recipe for Out-Of-Memory (OOM) errors. If writing to another table, use SqlBulkCopy / MySqlBulkLoader for bulk insertion.

If it's for statistical aggregation—perform the calculations within SQL rather than pulling data to the application layer, or better yet, use an OLAP database or pre-computed aggregated wide tables.

Regardless of the scenario, there are a few universal best practices:

  • Select only the necessary columns; 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 is known as the N+1 problem

13. What are the differences in storage structure between clustered and non-clustered indexes? What is a bookmark lookup (table lookup)? What considerations should be taken into account when designing primary keys?

A clustered index is the data itself. Its leaf nodes do not store pointers, but entire rows of data. Therefore, the physical order of the data on disk is determined by the clustered index. Because 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, the first non-null unique index is used. If neither exists, a hidden rowid is generated.

The leaf nodes of a non-clustered index (secondary index) store the indexed column(s) + primary key value. Therefore, when querying other fields using a secondary index, you must first retrieve the primary key and then perform a lookup in the clustered index to fetch the complete row. This process is called bookmark lookup (or "回表" in Chinese technical contexts).

This leads to several practical points:

  • Primary keys should be short and monotonically increasing. Use auto-increment IDs or Snowflake IDs. Avoid 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 storage space. Moreover, the primary key is redundantly stored in every secondary index. The longer the primary key, the larger all indexes become.
  • Covering index: If all the fields needed for a query are contained within the secondary index, no bookmark lookup is required. This results in a significant performance improvement and is one of the most common techniques for index optimization.

A note on the difference in SQL Server: The concepts are the same, but SQL Server allows tables without a clustered index. Such tables are called heap tables, and row locations are identified by RIDs.


14. What are the core advantages of PostgreSQL compared to MySQL? Besides pgvector, what other commonly used extensions are available?

When discussing the advantages of PostgreSQL, I typically highlight three points:

First, it has the most complete support for SQL standards and features. Window functions, recursive CTEs, materialized views, FILTER, and LATERAL are all highly mature, making complex analytical SQL much more comfortable to write.

Second, it has an extremely rich type system. JSONB is truly indexable and queryable, not just stored as a string; there are also arrays, range types, enums, geometric types, IP types, and even the ability to define custom types and operators.

Third, it offers high extensibility. It supports multiple index types, expression indexes, and partial indexes; stored procedures can be written in multiple languages; and there is a complete plugin ecosystem.

Besides pgvector, commonly used plugins include:

  • PostGIS — the de facto standard for geospatial, an area where MySQL simply cannot compete
  • TimescaleDB — a time-series extension featuring hypertables + automatic partitioning + columnar compression, very commonly used in IoT scenarios
  • Citus — distributed sharding, turning PG into a distributed database
  • pg_stat_statements — slow SQL statistics, which I consider a must-install
  • 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. Could you explain Kafka's core architecture and design philosophy? Why does it have such high throughput? What are the positioning differences compared to RabbitMQ?

Kafka is essentially a distributed commit log, functioning less like a traditional message queue and more like a replayable data pipeline.

Core Concepts: A Topic is divided into multiple Partitions. Each Partition is an ordered log file that supports only append-only writes, where every message has an offset. Producers use hashing on the key to determine which partition to write to. Since identical keys always land in the same partition, local ordering is guaranteed. The consumer side uses a consumer group model, where a single partition can only be consumed by one consumer within the group. Therefore, the upper limit of consumption concurrency is the number of partitions—this is critical; if the partition count is insufficient, adding more consumers will not help.

Why It’s Fast: Three main reasons—sequential disk writes (writing sequentially to disk is not slower than random writes to memory), zero-copy (sendfile transfers data directly from the page cache to the network card), and batching + compression.

High Availability is achieved through 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. 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 reprocessing to fix bugs.

Difference from RabbitMQ: Kafka is a high-throughput streaming platform using a pull model, ideal for massive data pipelines such as logs, event tracking, and IoT. 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 share a real-world scenario from your project and explain how it was implemented.

AOP addresses the problem of cross-cutting concerns—code that has nothing to do with business logic but needs to 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, requiring changes in hundreds of places whenever an update is needed, and business logic would be buried under boilerplate code. AOP's approach is to extract them into independent aspects and weave them in without modifying the business code.

There are several implementation approaches in .NET: middleware and filters (ActionFilter) in ASP.NET Core are essentially AOP; for finer-grained control, you can use DI interceptors, such as Castle DynamicProxy or AspectCore; there are also compile-time weaving solutions like Fody and source generators.

Here is an example from my experience—device operation auditing. In our system, every control command sent to a device must be logged: who issued it, when, which device was targeted, what parameters were used, whether it succeeded or failed, and how long it took. Compliance requirements mandate that not a single record can be missing.

If logging were manually written in each control method, the first issue would be duplication, and the second is that someone always forgets to add it, making it impossible to trace issues when they occur. My solution was to define an [Audit] attribute paired 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 logs the exception stack trace. All this is written uniformly to an audit table. Business methods only need a single line for the attribute, allowing them to focus solely on business logic.

Later, we applied the same approach to transactions with a [Transactional] attribute, where the interceptor handles Begin / Commit / Rollback, resulting in much cleaner business code.


17. What index types does PostgreSQL support? What data and query scenarios are each suitable for?

PostgreSQL index types are far more diverse than MySQL's. Here are the six most commonly used:

  • B-tree — The default. Supports equality and range queries, covering 90% of use cases.
  • Hash — Supports only equality queries. Rarely used, as B-tree covers most of its use cases.
  • GIN — Generalized Inverted Index. Ideal for scenarios where a single field contains multiple values, such as JSONB, arrays, and full-text search. When paired with pg_trgm, it can also accelerate LIKE '%xx%' queries.
  • GiST — Generalized Search Tree. Used for geometric and spatial data (the foundation of PostGIS), range types, and nearest-neighbor queries.
  • SP-GiST — Space-Partitioned Generalized Search Tree. Suitable for unbalanced data structures, such as IP addresses and point data.
  • BRIN — Block Range Index. Stores only the minimum and maximum values for each data block, resulting in an extremely small index size. The prerequisite is that the data is physically ordered according to the indexed column. This is a perfect fit for IoT and log tables where data is inserted in chronological order; a table of tens of GBs may have an index of only a few MBs.

Additionally, 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 specific subset of data you need to query, resulting in a much smaller index 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 heap fetches (table lookups).

18. What is the fundamental difference between event and delegate in C#? What restrictions does event impose in terms of encapsulation? In what scenarios should each be used?

In a nutshell: a delegate is a type, while an event is a layer of encapsulation built on top of 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 centered on "restricting what external code can do":

  1. If a delegate field is public, external code can assign to it directly using =, which can accidentally overwrite all registered subscribers. With an event, external code is restricted to += and -= only; direct assignment will not compile.
  2. External code can directly Invoke a delegate field. An event, however, can only be triggered from within the class that declares it; external attempts to trigger it are not allowed.

Therefore, the usage guidelines are clear: use an event to expose "notifications"—you notify others of what happened; they can only subscribe, not decide who receives the notification or trigger it on your behalf. Use delegates for passing callbacks, such as Func<T> or Action<T> as method parameters.

One practical detail: check for null before triggering an event. The standard pattern is MyEvent?.Invoke(this, args). In multithreaded scenarios, using ?. already avoids race conditions (the compiler first takes a local copy).


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 (LOH)? What coding practices can help reduce GC pressure in everyday development?

The .NET GC is generational + mark-and-sweep + compaction.

Generational collection is the core concept, based on the empirical observation that most objects have short lifetimes. Therefore, objects are categorized into:

  • Gen 0 — Newly allocated objects. Collection here is the most frequent and fastest; the vast majority of objects are reclaimed here.
  • Gen 1 — Objects that survive Gen 0 collection. It 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 operation.
  • LOH (Large Object Heap) — Objects 85,000 bytes or larger are placed directly here. Logically, they belong to Gen 2. By default, they are not compacted, which can easily lead to memory fragmentation.
  • POH (Pinned Object Heap) — Introduced in .NET 5, this heap stores pinned objects, preventing them from hindering compaction in the regular heap.

Collection Process: Starting from GC Roots (local variables on thread stacks, static fields, GC handles, and registers), a reachability analysis is performed. All accessible objects are marked. The remaining objects are considered garbage and are cleaned up. Afterward, memory is compacted by moving surviving objects together to eliminate fragmentation, and references are updated accordingly.

Modes: There is Workstation GC and Server GC (which uses multiple heaps and threads across multiple cores, and is the default for server applications). 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 avoid frequent allocation of arrays larger than 85K. Use ArrayPool<T> / MemoryPool<T> for reuse.
  • Use Span<T> and struct in hot paths to reduce heap allocations.
  • Avoid writing finalizers (destructors) unnecessarily. Objects with finalizers survive one additional generation and enter the finalization queue. Use IDisposable + using for unmanaged resources.
  • Use StringBuilder for concatenating large strings.

20. What targeted optimizations do time-series databases make over relational databases in terms of storage structure, write model, and query capabilities? In what scenarios would you choose a time-series database?

Time-series databases are specialized for the data shape of "timestamp + device/metric labels + value". The characteristics of this type of data are: append-only, almost no updates or deletions, write-heavy and read-light, and queried by time range. Relational databases are designed for general-purpose use, so they are not cost-effective for this kind of data.

The differences mainly lie in four aspects:

Storage: Time-series databases use time-based partitioning + columnar storage. Since values in the same column have 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 has limited compression potential.

Write Performance: Time-series databases are optimized for high-frequency sequential appends, handling millions of points per second as a standard level. Relational databases use B+ trees, where random writes plus maintaining multiple secondary indexes lead to severe write amplification.

Querying: Time-series databases have built-in time functions—downsampling, interpolation, sliding windows, first/last, and time alignment—solvable with a single SQL statement. Relational databases require writing numerous window functions and self-joins.

Lifecycle Management: Time-series databases come with retention policies, automatic expiration, and automatic downsampling. Once configured, they require no further attention. Relational databases require you to write scheduled tasks to delete data.

However, time-series databases have clear weaknesses: they are not good at complex JOINs, lack strong transactions, do not support frequent UPDATEs, and are not suitable for storing relational business data.

Therefore, the actual architecture generally involves using both types of databases together: device profiles, users, permissions, and orders are stored in PostgreSQL / MySQL, while collected point data is stored in TDengine / InfluxDB / TimescaleDB. Business queries are assembled at the application layer.


21. What are the underlying mechanisms 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 pillars: HTTP/2 + Protobuf.

Protobuf is a binary serialization format that produces much smaller payloads than JSON and serializes faster. It also enforces a strong contract—you write a single .proto file, which generates client and server code in various languages. This makes cross-language collaboration effortless, and interface mismatches are caught at compile time rather than at runtime.

HTTP/2 brings multiplexing (multiple requests over a single connection, eliminating the need to queue like in HTTP/1.1), header compression, and persistent connections.

The four calling modes are where gRPC outperforms REST: unary calls, server streaming, client streaming, and bidirectional streaming. These modes naturally support real-time push, large file chunked transfer, and long-lived command delivery, without requiring additional WebSocket setup.

Suitable scenarios: internal microservice communication, streaming communication requirements, and teams with mixed language stacks.

Limitations to consider:

  • 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 test directly with browsers or curl, and must use tools like grpcurl
  • Proto evolution must follow strict rules: once a field number is used, it cannot be changed or reused. Deleted fields must be marked with reserved; otherwise, hard-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 use.


22. How to control concurrent connection limits on the SignalR server? How to implement both total connection limits and per-user connection limits, respectively? What should be considered in a clustered deployment?

SignalR itself does not have a direct "maximum connections" configuration option. This needs to be handled at several levels:

First, handle it at the business layer with your own counting logic — this is the most flexible and commonly used approach. In the Hub's OnConnectedAsync, use Interlocked.Increment to maintain a global counter. 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 similarly: use a ConcurrentDictionary<userId, connection count>. If the same user exceeds a limit, say 3 connections, kick out the oldest connection or reject new ones. This prevents a single user from opening dozens of tabs.

Second, add 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 to configure — these aren't about limiting connection counts, but they directly affect whether the service can handle the load:

  • ClientTimeoutInterval and KeepAliveInterval — promptly clean up zombie connections from disconnected clients, otherwise the connection count will only increase and never decrease
  • MaximumReceiveMessageSize — limit the size of a single message to prevent someone from sending an oversized packet that blows up memory
  • StreamBufferCapacity — limit the streaming buffer size

Finally, a critical point: clustered deployment. When running multiple instances, you must configure a Redis backplane (or Azure SignalR Service) for message broadcasting. Also, connection counting must be stored in Redis, otherwise each instance will count connections independently, and the limits will be inaccurate.


23. How to Avoid Memory Overflows and Request Timeouts When Exporting Millions of Rows to Excel? How Should the Read Side, Write Side, and Interaction Flow Be Designed?

The main pitfall with this problem is one thing: you cannot load all the data into memory at once. Creating a workbook with 1 million rows using the standard mode of EPPlus or NPOI will inevitably cause an Out Of Memory (OOM) error. Furthermore, the maximum limit for a single sheet in an xlsx file is 1,048,576 rows, which is right at the 1-million-row scale.

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 queue a background task and immediately return a task ID. Once the background process finishes generating the file, it uploads it to object storage and then notifies the user to download it via SignalR or an in-site message. You cannot let users wait in their browser for several minutes, as both the gateway and the browser will time out.

2. Stream the reading process. Use DbDataReader to read row by row, or fetch data in batches using a primary key cursor, reading, writing, and releasing memory as you go to keep memory usage constant. Absolutely do not fetch everything at once with ToList().

3. Use a library that supports streaming on the writing end.

  • MiniExcel is my top choice. Its SaveAs method can directly accept an IEnumerable or IDataReader, keeping memory usage nearly constant, and the code is very simple.
  • Alternatively, you can use OpenXML SDK in SAX mode (OpenXmlWriter). This offers the best performance but involves more verbose code.
  • NPOI's SXSSF is also an option, as it spills rows to temporary files.

A few other practical points:

  • If you exceed 1 million rows, you need to split the data across multiple sheets or files. For example, create one sheet per 500,000 rows, or package multiple files into a zip archive.
  • If there are no strict formatting requirements, export directly to CSV. This is an order of magnitude faster and can handle hundreds of MBs without pressure. Users can still open it in Excel just fine.
  • Write the file to disk or object storage. Do not assemble byte arrays in memory.

24. What is baud rate? Is it the same as bit rate? How does the baud rate level affect communication? Besides baud rate, what other parameters must match for serial communication?

Baud rate is the number of symbols transmitted per second. In the case of serial ports, where one symbol equals one bit, it is numerically equal to 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 the context of serial communication.

There is an easily overlooked point when calculating conversion: To transmit one byte over a serial port, you actually send 1 start bit + 8 data bits + 1 stop bit = 10 bits. Therefore, at 9600 baud, the actual effective throughput is approximately 960 bytes per second, not 1200. You must account for this when calculating polling cycles.

The difference between high and low baud rates:

  • High baud rates transmit faster, but have poorer noise immunity and shorter transmission distances, requiring high-quality cabling and precise clock synchronization at both ends. Even a slight clock deviation will shift the sampling point, resulting in corrupted data.
  • Low baud rates are slower, but offer greater stability and longer transmission distances.

Therefore, in industrial control environments using RS485 over long distances, many systems reliably use 9600 or 19200 baud for stability. Higher rates like 115200 or above are typically reserved for short-distance board-level communication.

The most critical point: the baud rates of the sender and receiver must match exactly. Even a slight mismatch will result in garbled output—this is the most common issue encountered when debugging serial ports.

In addition to baud rate, data bits, stop bits, parity, 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), so if the baud rate changes, this timeout duration must also be adjusted accordingly.


25. What thread synchronization mechanisms are available in C#? What scenarios are they suited 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. Lightest option, preferred for counters and flags, an order of magnitude faster than lock

Various locks:

  • Monitor.TryEnter —— lock with a timeout; if it can't acquire, proceed with alternative logic, which can prevent deadlocks
  • ReaderWriterLockSlim —— For read-heavy, write-light scenarios; multiple reader threads can enter simultaneously, while writers have exclusive access
  • Mutex —— Cross-process synchronization, e.g., ensuring only one instance of a program is running
  • SpinLock —— Use when the critical section is extremely short; spin-waits without thread switching, saving context-switch overhead

Inter-thread signal coordination:

  • ManualResetEventSlim, AutoResetEvent, CountdownEvent, Barrier

Actually, a more recommended approach is "don't lock yourself":

  • Concurrent collections —— ConcurrentDictionary, ConcurrentQueue, BlockingCollection; internally implemented with lock-free or fine-grained locking, faster and less error-prone than wrapping your own code in lock
  • Channel<T> —— Modern implementation of the producer-consumer model; easier to use than hand-rolling a blocking queue
  • No shared state —— Use immutable objects, ThreadLocal, or per-thread data merged at the end, eliminating contention at the root. Parallel.For's local state aggregation follows this idea

Two critical caveats:

  1. You cannot await inside a lock. For mutual exclusion in async scenarios, use SemaphoreSlim.WaitAsync(), or wrap your own AsyncLock.
  2. The object passed to lock should be a private readonly object. Don't lock this, typeof(T), or string literals — these are accessible externally and can easily cause unintended deadlocks.

One final note on volatile: it addresses visibility and instruction reordering, not mutual exclusion — don't use it as a lock.


26. What are the differences in the underlying structures of List and Dictionary? How much does lookup performance differ under 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 with a linked structure for handling collisions. These structural differences determine that their strengths are completely 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) during resize) O(1) amortized
Insert/Delete in middle Insert / RemoveAt O(n), must shift all subsequent elements —
Remove by key Remove O(n) O(1)
Iteration O(n) O(n)

How much difference does it make with large datasets: Finding a single element among 1 million items, a List requires an average of 500,000 comparisons, while a Dictionary computes one hash to locate the bucket directly. The difference is several orders of magnitude, and it grows larger as the dataset size increases—List performance degrades linearly, whereas Dictionary lookup remains essentially constant.

But Dictionary isn't without costs:

  • Higher memory overhead: Each entry stores the key, value, hashCode, and next pointer. Additionally, the bucket array itself contains redundant space, making the total memory usage roughly two to three times that of a List.
  • No ordering guarantee: Iteration order is not guaranteed, so business logic must never rely on it.
  • Pure iteration is actually slower: Lists have contiguous memory, leading to high CPU cache hit rates. For full-iteration scenarios, Lists are faster.
  • For very small datasets (under a few dozen items), linear search in a List may even be faster than Dictionary lookup, as it avoids the overhead of hash computation.

Worst-case scenario occurs when all keys hash-collide, 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:

  • Index-based access, need to maintain order, primarily iterating → List
  • Frequent lookups by key → Dictionary
  • Only checking existence, no value needed → HashSet<T>
  • Need both ordering and fast lookup → SortedDictionary (Red-Black tree, O(log n))

Finally, let me share a real-world pitfall I’ve encountered, as this topic is often probed further in practical contexts:

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

Performance degrades noticeably with tens of thousands of records and becomes completely unmanageable with millions. The fix is straightforward: create indexes 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)

The overall complexity drops from O(n²) to O(n). This is the most typical example of applying time complexity knowledge in practice.


Final Thoughts

  • Each question's content is longer than what is typically spoken aloud during an interview. Focus on the bolded keywords when answering, finish your point, then pause and let the interviewer decide whether to dig deeper.
  • When asked questions like "How did you solve this?", describe the scenario first, then present the solution. Ideally, include real numbers from your project (data volume, QPS, latency), which are 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. Making things up is the biggest deduction.
  • Questions 5, 7, 12, and 23 are open-ended architecture questions. There is no standard answer; the focus is on your thought process. It is a plus to ask about requirements and constraints before proposing a solution.