Lock Contention

2 posts

cloudflare3 min readCurated summary

Our billing pipeline was suddenly slow. The culprit was a hidden bottleneck in ClickHouse

Cloudflare’s migration to per-namespace retention in ClickHouse unexpectedly caused billing queries to slow dramatically, even though I/O, memory usage, rows scanned, and parts read appeared normal. The hidden bottleneck was query-planning lock contention: as the new `(namespace, day)` partitioning scheme multiplied the number of data parts, queries spent much of their time waiting for a mutex protecting the table’s active-part list. Investigation with flame graphs exposed the issue, leading Cloudflare to develop ClickHouse fixes. ## A Petabyte-Scale ClickHouse Platform - Cloudflare stores more than 100 PB across dozens of ClickHouse clusters. - Its Ready-Analytics system lets hundreds of internal teams share a massive table using: - A `namespace` to identify each dataset - A standard schema - A primary key of `(namespace, indexID, timestamp)` - By December 2024, the system contained more than 2 PiB and ingested millions of rows per second. ## The Limits of a Global Retention Policy - The table was partitioned by day, and a retention job dropped partitions older than 31 days. - This prevented teams from applying different retention periods: - Some needed years of data. - Others needed only a few days. - Teams requiring custom retention had to use more complicated, conventional table setups. ## Moving to Per-Namespace Partitions - Cloudflare considered: - Creating a separate table for every namespace. - Changing the partition key from `(day)` to `(namespace, day)`. - They chose the second option because it preserved the existing retention workflow while enabling namespace-level deletion. - The team expected more total parts but assumed query performance would remain stable because queries already filtered by namespace. - Migration began in January 2025 using ClickHouse’s `Merge` table feature. ## Billing Queries Begin to Slow - By late March 2025, billing aggregation jobs were approaching their daily deadlines. - Standard performance indicators looked healthy: - I/O and memory were normal. - Queries scanned no more rows or parts than before. - Query latency correlated strongly with the growing total number of parts in the cluster, revealing that merely having more parts could hurt performance. ## Finding the Hidden Lock Bottleneck - Cloudflare used ClickHouse’s `trace_log` to generate flame graphs for leaf `SELECT` queries. - CPU traces showed that roughly 45% of sampled CPU time was spent in `filterPartsByPartition`, which filters parts during query planning. - Reordering pruning heuristics produced only a 5% improvement. - “Real” traces, which include waiting and inactive threads, exposed the real issue: - More than half of query time was spent waiting on a mutex protecting the table’s active-part list. - Every query-planning thread had to contend for the `MergeTreeData` lock. - The migration increased the number of parts enough to make this previously unnoticed planning bottleneck dominant. The main lesson is that ClickHouse performance can degrade during query planning even when execution metrics look normal. When partitioning changes substantially increase part counts, teams should monitor planning time and lock contention—not just data scanned, I/O, or memory—and use real-time flame graphs to identify waits hidden by CPU-only profiling.

Read original(opens in new tab)
datadog3 min readCurated summary

.NET Continuous Profiler: Exception and lock contention

Datadog’s .NET continuous profiler can diagnose performance problems that CPU and wall-time profiling may miss: excessive exceptions and lock contention. Exceptions consume significant CPU and latency, while locks increase request latency through waiting rather than active computation. By collecting exception details and measuring contention duration with low overhead, the profiler helps developers identify the code and runtime conditions responsible. ## Exception Profiling - The CLR notifies the profiler through `ICorProfilerCallback::ExceptionThrown`, providing the exception’s `ObjectID`. - `ExceptionProvider::OnExceptionThrown` extracts details such as: - Exception type - Thread ID - Source location - The profiler maps the exception object to its `ClassID` using `ICorProfilerInfo::GetClassFromObject`. - Type names are resolved and cached by the `FrameStore`. - Exception messages require reading the private `System.Exception._message` field: - The profiler locates `System.Exception` in `mscorlib` or `System.Private.CoreLib`. - `GetModuleMetaData` provides access to assembly metadata. - `FindTypeDefByName` locates the type definition. - `GetClassFromTokenAndTypeArgs` obtains its `ClassID`. - `GetClassLayout` identifies field offsets. - `FindField` locates `_message`. - `GetStringLayout2` provides the string buffer and length needed to read the message. - Collecting exception counts by type, message, and call site makes it possible to replace expensive exception-driven control flow with cheaper checks such as `TryParse`. ## Lock Contention Monitoring - Standard .NET monitoring exposes contention counts, but generally not how long threads waited or where the contention originated. - The CLR emits: - `ContentionStart` when a thread begins waiting - `ContentionStop` when it acquires the lock - On .NET Framework, contention duration is calculated from timestamps recorded for each thread because `ContentionStop` does not include the duration. - Since .NET 8, `ContentionStart` includes the lock’s `ObjectID` and the ID of the thread holding it, allowing the profiler to identify the blocking thread. - .NET Framework exposes counters such as `Contention Rate / Sec` and `Total # of Contentions`; .NET Core provides `monitor-lock-contention-count` through `dotnet-counters`. - These counters alone do not reveal the duration or cause of waits. ## Consuming CLR Events - Since .NET 5, profilers can synchronously receive CLR events through `ICorProfilerCallback10::EventPipeEventDelivered`. - Datadog’s `ClrEventParser` interprets event payloads based on event IDs and keywords. - The parsed duration is passed to `ContentionProvider::OnContention`. - Runtime differences require version-specific handling because event payloads are not identical across .NET Framework and .NET Core. The practical recommendation is to profile both exception frequency and lock-wait duration, rather than relying only on CPU usage or contention counters. This reveals inefficient exception-based logic and identifies locks—and, on newer runtimes, the threads holding them—that materially affect application latency.

Read original(opens in new tab)