From 4060876fb4591d82af9f9aba3487f3cf5ab195e4 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Tue, 13 Oct 2026 10:08:51 +0100 Subject: [PATCH] DOC-7175 Document StackExchange.Redis v4 client-side caching Adds a "Connect using client-side caching" section to dotnet/connect.md (after SCH), a production-usage checklist stub, and a StackExchange.Redis row plus relatedPages entry on the client-side caching overview. Written against the 4.0.127-alpha pre-release and the upstream draft docs/ClientSideCaching.md at v4 commit cc2c362466; option names and defaults were cross-checked against src/StackExchange.Redis/Caching/*.cs at the 4.0.127-alpha tag (CacheOptions, CachePolicy, CacheAdmission, CacheTrackingMode). None of the caching types carry [Experimental], so unlike the SCH section there is no SERxxx suppression note. The example deliberately reads the key three times. SE.Redis admits a reply on its second miss by default (OnRepeatedMiss, a TinyLFU doorkeeper), so the two-read MONITOR demo every other client page uses would show both GETs reaching the server. The overview page's sentence about clients differing exists for the same reason: its "first access hits the server, later ones don't" MONITOR advice and its "HSCAN is never cached" claim are both false for SE.Redis. The page uses the existing IDatabase style (Andy's call), which the cache supports because it sits below the API. The one exception is the WithMaxCacheAge/WithoutCache subsection: those exist only on RespDatabaseContext, so it uses the v4 command-group API and says so. Nothing here was run. NuGet is on the sandbox denylist, so the alpha package could not be restored, and the snippets are unverified. Learned: SE.Redis v4 caches on the second read by default, so CSC demos need three reads or OnFirstMiss Rejected: db.Context.WithMaxCacheAge(...).AsDatabase(muxer) to keep IDatabase style | AsDatabase is documented upstream as a transitional database whose unmigrated commands throw Constraint: the connect example must keep three StringGet calls unless it also sets Admission = CacheAdmission.OnFirstMiss Gaps: no snippet compiled or run; verify against the released 4.x package before merge Recheck: when the first non-alpha 4.x release ships (version note, TODO comment, overview row, option names/defaults) Ticket: DOC-7175 Co-Authored-By: Claude Opus 5.5 --- .../develop/clients/client-side-caching.md | 8 + content/develop/clients/dotnet/connect.md | 192 ++++++++++++++++++ content/develop/clients/dotnet/produsage.md | 11 + 3 files changed, 211 insertions(+) diff --git a/content/develop/clients/client-side-caching.md b/content/develop/clients/client-side-caching.md index e3e588ee18..97ba57eeb3 100644 --- a/content/develop/clients/client-side-caching.md +++ b/content/develop/clients/client-side-caching.md @@ -21,6 +21,7 @@ relatedPages: - /develop/clients/nodejs/connect#connect-using-client-side-caching - /develop/clients/jedis/connect#connect-using-client-side-caching - /develop/clients/go/connect#connect-using-client-side-caching +- /develop/clients/dotnet/connect#connect-using-client-side-caching topics: - client-side-caching - performance @@ -94,6 +95,7 @@ The following client libraries support CSC from the stated version onwards: | [`Jedis`](/content/develop/clients/jedis/connect.md#connect-using-client-side-caching) | v5.2.0 | | [`node-redis`](/content/develop/clients/nodejs/connect.md#connect-using-client-side-caching) | v5.1.0 | | [`go-redis`](/content/develop/clients/go/connect.md#connect-using-client-side-caching) | v9.22.0 | +| [`StackExchange.Redis`](/content/develop/clients/dotnet/connect.md#connect-using-client-side-caching) | v4.0 (pre-release) | Note that some other clients support the [`CLIENT TRACKING`](/content/commands/client-tracking.md) command to configure CSC on the server, but this does not mean they support the features required for CSC themselves. @@ -116,6 +118,12 @@ will use cached data, except for the following: - Redis Search commands (with the `FT.*` prefix), such as [`FT.SEARCH`](/content/commands/ft.search.md). +The details vary between client libraries, so check the client-side caching +section for your client. For example, +[StackExchange.Redis](/content/develop/clients/dotnet/connect.md#connect-using-client-side-caching) +caches `HSCAN` pages, and by default it caches a reply the second time you read +it rather than the first. + You can use the [`MONITOR`](/content/commands/monitor.md) command to check the server's behavior when you are using client-side caching. Because `MONITOR` only reports activity from the server, you should find the first cacheable diff --git a/content/develop/clients/dotnet/connect.md b/content/develop/clients/dotnet/connect.md index 8815a1fe1a..36b0c9aab6 100644 --- a/content/develop/clients/dotnet/connect.md +++ b/content/develop/clients/dotnet/connect.md @@ -301,6 +301,198 @@ muxer.ServerMaintenanceEvent += (object sender, ServerMaintenanceEvent e) => { `MaintenanceType` property, so you can tell whether a failure happened because of ongoing maintenance. +## Connect using client-side caching + +Client-side caching is a technique to reduce network traffic between +the client and server, resulting in better performance. See +[Client-side caching introduction](/content/develop/clients/client-side-caching.md) +for more information about how client-side caching works and how to use it effectively. + + +> [!NOTE] +> Client-side caching requires StackExchange.Redis v4.0, which is currently +> available as the `4.0.127-alpha` pre-release, so its API may change before +> the final release. +> To maximize compatibility with all Redis products, client-side caching +> is supported by Redis v7.4 or later. +> +> Client-side caching requires version 3 of the +> [Redis serialization protocol (RESP3)](/content/develop/reference/protocol-spec.md#resp-versions) +> and a server that allows the [`CLIENT TRACKING`](/content/commands/client-tracking.md) +> command. If either is missing, the connection fails with an error rather +> than running without cache invalidation. + +Client-side caching is disabled by default. To enable it, set `Protocol` to +`RedisProtocol.Resp3` and pass a `CacheOptions` object in the `ClientCache` +property of `ConfigurationOptions`. You can only configure the cache in code, +not with a configuration string. The cache works below the API, so it applies +to all of the `IDatabase` methods, both synchronous and asynchronous: + +```csharp +using StackExchange.Redis; +using StackExchange.Redis.Caching; + +var options = ConfigurationOptions.Parse("localhost:6379"); +options.Protocol = RedisProtocol.Resp3; // RESP3 required for client-side caching +options.ClientCache = new CacheOptions { + Prefixes = ["city:"], + MaxBytes = 64 * 1024 * 1024, +}; + +ConnectionMultiplexer muxer = ConnectionMultiplexer.Connect(options); +IDatabase db = muxer.GetDatabase(); + +db.StringSet("city:1", "New York"); +db.StringGet("city:1"); // Retrieved from the server +db.StringGet("city:1"); // Retrieved from the server and cached +db.StringGet("city:1"); // Retrieved from the cache +``` + +By default, StackExchange.Redis caches a reply the second time you read it, +not the first, so values you read only once never take up space in the cache. +You can see this if you connect to the same Redis database +with [`redis-cli`](/content/develop/tools/cli.md) and run the +[`MONITOR`](/content/commands/monitor.md) command. With caching enabled, +the server sees the first two `StringGet("city:1")` calls but not the third, +which the client satisfies from the cache. Set `Admission` to +`CacheAdmission.OnFirstMiss` to cache every reply the first time you read it. + +There is one cache for each `ConnectionMultiplexer`, shared by all of +its databases. + +### Tracking modes + +The `TrackingMode` option sets how the server decides which changes to announce: + +- `CacheTrackingMode.Broadcast` (the default): the server announces every + change to keys that match the `Prefixes` you set, and doesn't record which + keys you read. This is cheap for the server, but the client receives + invalidations for keys it never cached. Without `Prefixes`, the client + receives an invalidation for every write to every key, which is a lot of + traffic on a busy server. Keys outside the prefixes are not cached. + Prefixes must not overlap (for example, `"app:"` and `"app:user:"` together + are rejected), and they are compared with the key as it is sent, so include + any key prefix that you add with `WithKeyPrefix()`. +- `CacheTrackingMode.PerKey`: the server records which keys this client read + and announces changes only to those keys. This is more precise, but uses + server memory for each client. You can't use `Prefixes` in this mode. + +### Configuration options + +The `CacheOptions` object accepts the following options: + +| Name | Description | +| :-- | :-- | +| `Enabled` | Whether the cache is enabled. The default is `true`. Use this to keep your options in place but switch the cache off. | +| `TrackingMode` | The tracking mode (see [Tracking modes](#tracking-modes)). The default is `CacheTrackingMode.Default`, which is currently `Broadcast`. | +| `Prefixes` | The key prefixes to track and cache in `Broadcast` mode. By default, all keys are tracked. | +| `MaxBytes` | A memory limit for cached replies. When the cache exceeds it, the client evicts the oldest entries. By default, there is no limit. | +| `MaxEntries` | The maximum number of entries the cache can hold. By default, there is no limit. Use this with `MaxBytes` when your replies are small and numerous. | +| `MaxPayloadBytes` | The largest single reply that can be cached. The default is 1 MiB. | +| `EvictionSampleSize` | The number of entries that eviction considers each time it chooses one to remove. The default is 8. Larger values make better choices but cost more. | +| `SweepInterval` | How often the client reclaims memory held by invalidated or expired entries. The default is 10 seconds. | +| `DefaultPolicy` | A `CachePolicy` object that sets how entries behave as they age (see the next table). The default is `CachePolicy.Default`. | +| `Admission` | When a reply is added to the cache. The default is `CacheAdmission.Default`, which is currently `OnRepeatedMiss` (cache a reply the second time it's read). Use `OnFirstMiss` to cache every reply the first time it's read. | + +The `CachePolicy` object accepts the following options: + +| Name | Description | +| :-- | :-- | +| `TimeToLive` | The longest time the client serves an entry, whether or not it was invalidated. The default is 1 minute. | +| `RefreshAfter` | The age at which the client starts refreshing an entry in the background, while it continues to serve the cached value. Off by default. | +| `InvalidationGracePeriod` | How long the client continues to serve an entry after another client's change invalidates it, while a background refresh runs. This deliberately serves values that are out of date, so keep it short. It never applies to your own writes. Off by default. | + +`RefreshAfter` and `InvalidationGracePeriod` stop every reader of a frequently +read key from going to the server at the same moment when its entry expires +or is invalidated. The example below sets both: + +```csharp +options.ClientCache = new CacheOptions { + Prefixes = ["catalog:"], + DefaultPolicy = new CachePolicy { + TimeToLive = TimeSpan.FromMinutes(1), + RefreshAfter = TimeSpan.FromSeconds(45), + InvalidationGracePeriod = TimeSpan.FromMilliseconds(250), + }, +}; +``` + +### Bypassing the cache + +Pass `CommandFlags.NoClientCache` to stop a single command from reading from +or writing to the cache: + +```csharp +RedisValue exact = db.StringGet("city:1", CommandFlags.NoClientCache); +``` + +You can also create a *context* that limits how the cache is used for all the +commands you send through it. Contexts are part of the command group API that +StackExchange.Redis v4.0 introduces, so the examples below use the command +group methods rather than the `IDatabase` methods: + +```csharp +// Accept cached replies only if they are less than one second old. +var fresh = db.Context.WithMaxCacheAge(TimeSpan.FromSeconds(1)); +RedisValue recent = await fresh.Strings.GetAsync("city:1"); + +// Bypass the cache completely. +var uncached = db.Context.WithoutCache(); +RedisValue latest = await uncached.Strings.GetAsync("city:1"); +``` + +### Which commands use the cache + +StackExchange.Redis caches the replies from read commands on keys, including +`HSCAN`, `SSCAN`, and `ZSCAN` pages, nil replies for missing keys, and the +read-only script commands +([`EVAL_RO`](/content/commands/eval_ro.md) and [`EVALSHA_RO`](/content/commands/evalsha_ro.md)). +It never caches the following: + +- Write commands. +- Commands that don't name a key, such as [`SCAN`](/content/commands/scan.md) + and [`INFO`](/content/commands/info.md). +- Random reads, such as [`SRANDMEMBER`](/content/commands/srandmember.md) and + [`HRANDFIELD`](/content/commands/hrandfield.md). +- Commands that report time or access, such as [`TTL`](/content/commands/ttl.md) + and [`OBJECT FREQ`](/content/commands/object-freq.md). +- Reads inside a transaction, and commands sent with `CommandFlags.FireAndForget`. +- Error replies and replies larger than `MaxPayloadBytes`. + +A read-only script is invalidated only when a key in its `KEYS` array changes. +If a script reads a key that it doesn't declare, its cached result can be out +of date until `TimeToLive` expires, so declare every key or use +`CommandFlags.NoClientCache` for that script. + +### Consistency + +StackExchange.Redis guarantees the following: + +- You always read your own writes. A write through the multiplexer + invalidates the keys it names before it is sent to the server. +- You see writes from other clients shortly after they happen, when the + server's invalidation message arrives. +- If a connection fails, the client empties the whole cache. The + [`FLUSHDB`](/content/commands/flushdb.md) and + [`FLUSHALL`](/content/commands/flushall.md) commands also empty it. + +The server doesn't send an invalidation message when a key expires, so a +cached value can outlive its key until `TimeToLive` expires. If your app relies +on keys disappearing on time, use a short `TimeToLive` or use +`CommandFlags.NoClientCache` for those reads. + +### Monitoring the cache + +StackExchange.Redis doesn't provide statistics for the cache. Instead, check +the server: + +- Run [`CLIENT TRACKINGINFO`](/content/commands/client-trackinginfo.md) to + confirm that tracking is enabled. +- Compare the `total_commands_processed` field from + [`INFO stats`](/content/commands/info.md) before and after a workload. The + server never sees commands that the cache satisfies. +- Use `INFO commandstats` to see the same count for each command. + ## Multiplexing Although example code typically works with a single connection, diff --git a/content/develop/clients/dotnet/produsage.md b/content/develop/clients/dotnet/produsage.md index 2dbcc427cd..848d2db51c 100644 --- a/content/develop/clients/dotnet/produsage.md +++ b/content/develop/clients/dotnet/produsage.md @@ -25,6 +25,7 @@ for a recommendation. Use the checklist icons to record your progress in implementing the recommendations. ```checklist {id="dotnetprodlist"} +- [ ] [Client-side caching](#client-side-caching) - [ ] [Event handling](#event-handling) - [ ] [Timeouts](#timeouts) - [ ] [Exception handling](#exception-handling) @@ -36,6 +37,16 @@ progress in implementing the recommendations. The sections below offer recommendations for your production environment. Some of them may not apply to your particular use case. +### Client-side caching + +[Client-side caching](/content/develop/clients/client-side-caching.md) +involves storing the results from read-only commands in a local cache. If the +same command is executed again later, the results can be obtained from the cache, +without contacting the server. This improves command execution time on the client, +while also reducing network traffic and server load. See +[Connect using client-side caching](/content/develop/clients/dotnet/connect.md#connect-using-client-side-caching) +for more information and example code. + ### Event handling The `ConnectionMultiplexer` class publishes several different types of