Skip to content

Traffic Skew with Centrifugo's Sharded Pub/Sub in Redis Cluster #554

@allOwO

Description

@allOwO

I'd like to share an observation regarding Redis Cluster traffic skew when using Centrifugo's Sharded Pub/Sub, and ask for some advice.

Context & Observation

When load testing Sharded Pub/Sub with 32 partitions, traffic distributes well on a 4-shard Redis Cluster. However, scaling up reveals severe traffic skew due to how Redis CRC16 hashes short sequential partition indices:

Shards Configured Partitions Observation
4 32 Balanced
8 32 Highly skewed (4 out of 8 shards idle)
16 32 Highly skewed (8 out of 16 shards idle)

The Root Cause

In broker_redis.go, the Sharded Pub/Sub partition index is cast to a string and used directly as the Redis hash tag:

// builder appends: "prefix{" + idxStr + "}." + ch
builder.WriteString(idxStr)
// ...

Since Redis slot calculation depends entirely on the string inside {} (i.e. CRC16("0") to CRC16("31")), it leads to massive collisions on larger clusters.

A quick Python simulation (code at the bottom) confirms that half the shards receive 0 keys when scaling up.

16-shard distribution result snippet:

Shard 0 : 3 keys    Shard 8 : 2 keys
Shard 1 : 5 keys    Shard 9 : 6 keys
Shard 4 : 3 keys    Shard 12: 2 keys
Shard 5 : 5 keys    Shard 13: 6 keys
Shard 2,3,6,7,10,11,14,15: 0 keys (idle)

Looking for Advice

To workaround this, we are considering:

Approach Example Rationale
Explicit Prefix {pt0}ch Using custom hash tags avoids partition index skew.

Would introducing configurable explicit prefixes (acting as custom hash tags) be a good practice or an acceptable upstream feature? Thanks in advance for your insights!


Full Python Code & Results

crc16tab = [
    0x0000,0x1021,0x2042,0x3063,0x4084,0x50a5,0x60c6,0x70e7,
    0x8108,0x9129,0xa14a,0xb16b,0xc18c,0xd1ad,0xe1ce,0xf1ef,
    0x1231,0x0210,0x3273,0x2252,0x52b5,0x4294,0x72f7,0x62d6,
    0x9339,0x8318,0xb37b,0xa35a,0xd3bd,0xc39c,0xf3ff,0xe3de,
    0x2462,0x3443,0x0420,0x1401,0x64e6,0x74c7,0x44a4,0x5485,
    0xa56a,0xb54b,0x8528,0x9509,0xe5ee,0xf5cf,0xc5ac,0xd58d,
    0x3653,0x2672,0x1611,0x0630,0x76d7,0x66f6,0x5695,0x46b4,
    0xb75b,0xa77a,0x9719,0x8738,0xf7df,0xe7fe,0xd79d,0xc7bc,
    0x48c4,0x58e5,0x6886,0x78a7,0x0840,0x1861,0x2802,0x3823,
    0xc9cc,0xd9ed,0xe98e,0xf9af,0x8948,0x9969,0xa90a,0xb92b,
    0x5af5,0x4ad4,0x7ab7,0x6a96,0x1a71,0x0a50,0x3a33,0x2a12,
    0xdbfd,0xcbdc,0xfbbf,0xeb9e,0x9b79,0x8b58,0xbb3b,0xab1a,
    0x6ca6,0x7c87,0x4ce4,0x5cc5,0x2c22,0x3c03,0x0c60,0x1c41,
    0xedae,0xfd8f,0xcdec,0xddcd,0xad2a,0xbd0b,0x8d68,0x9d49,
    0x7e97,0x6eb6,0x5ed5,0x4ef4,0x3e13,0x2e32,0x1e51,0x0e70,
    0xff9f,0xefbe,0xdfdd,0xcffc,0xbf1b,0xaf3a,0x9f59,0x8f78,
    0x9188,0x81a9,0xb1ca,0xa1eb,0xd10c,0xc12d,0xf14e,0xe16f,
    0x1080,0x00a1,0x30c2,0x20e3,0x5004,0x4025,0x7046,0x6067,
    0x83b9,0x9398,0xa3fb,0xb3da,0xc33d,0xd31c,0xe37f,0xf35e,
    0x02b1,0x1290,0x22f3,0x32d2,0x4235,0x5214,0x6277,0x7256,
    0xb5ea,0xa5cb,0x95a8,0x8589,0xf56e,0xe54f,0xd52c,0xc50d,
    0x34e2,0x24c3,0x14a0,0x0481,0x7466,0x6447,0x5424,0x4405,
    0xa7db,0xb7fa,0x8799,0x97b8,0xe75f,0xf77e,0xc71d,0xd73c,
    0x26d3,0x36f2,0x0691,0x16b0,0x6657,0x7676,0x4615,0x5634,
    0xd94c,0xc96d,0xf90e,0xe92f,0x99c8,0x89e9,0xb98a,0xa9ab,
    0x5844,0x4865,0x7806,0x6827,0x18c0,0x08e1,0x3882,0x28a3,
    0xcb7d,0xdb5c,0xeb3f,0xfb1e,0x8bf9,0x9bd8,0xabbb,0xbb9a,
    0x4a75,0x5a54,0x6a37,0x7a16,0x0af1,0x1ad0,0x2ab3,0x3a92,
    0xfd2e,0xed0f,0xdd6c,0xcd4d,0xbdaa,0xad8b,0x9de8,0x8dc9,
    0x7c26,0x6c07,0x5c64,0x4c45,0x3ca2,0x2c83,0x1ce0,0x0cc1,
    0xef1f,0xff3e,0xcf5d,0xdf7c,0xaf9b,0xbfba,0x8fd9,0x9ff8,
    0x6e17,0x7e36,0x4e55,0x5e74,0x2e93,0x3eb2,0x0ed1,0x1ef0
]

def redis_crc16(s):
    crc = 0
    for b in s.encode('utf-8'):
        crc = ((crc << 8) ^ crc16tab[((crc >> 8) ^ b) & 0x00FF]) & 0xFFFF
    return crc

shard_counts_4 = {i: 0 for i in range(4)}
shard_counts_8 = {i: 0 for i in range(8)}
shard_counts_16 = {i: 0 for i in range(16)}

print(f"{'Key':<8} | {'Node 4-Shard':<14} | {'Node 8-Cluster':<14} | {'Node 16-Shard':<14}")
print('-' * 60)
for i in range(32):
    slot = redis_crc16(str(i)) % 16384
    
    # 4 Shard: each chunk is 4096 slots
    shard_4 = slot // 4096
    shard_counts_4[shard_4] += 1
    
    # 8 Shard: each chunk is 2048 slots
    shard_8 = slot // 2048
    shard_counts_8[shard_8] += 1
    
    # 16 Shard: each chunk is 1024 slots
    shard_16 = slot // 1024
    shard_counts_16[shard_16] += 1
    
    print(f"{i:<8} | {shard_4:<14} | {shard_8:<14} | {shard_16:<14}")

print('\nDistribution across 16 shards:')
for shard, count in shard_counts_16.items():
    print(f'Shard {shard:<2}: {count} keys')

Output:

Key      | Node 4-Shard   | Node 8-Shard   | Node 16-Shard 
------------------------------------------------------------
0        | 3              | 6              | 13            
1        | 2              | 4              | 9             
2        | 1              | 2              | 5             
3        | 0              | 0              | 1             
4        | 3              | 6              | 13            
5        | 2              | 4              | 9             
6        | 1              | 2              | 5             
7        | 0              | 0              | 1             
8        | 3              | 6              | 13            
9        | 2              | 4              | 9             
10       | 0              | 0              | 0             
11       | 1              | 2              | 4             
12       | 2              | 4              | 8             
13       | 3              | 6              | 12            
14       | 0              | 0              | 0             
15       | 1              | 2              | 4             
16       | 2              | 4              | 8             
17       | 3              | 6              | 12            
18       | 0              | 0              | 0             
19       | 1              | 2              | 4             
20       | 1              | 2              | 5             
21       | 0              | 0              | 1             
22       | 3              | 6              | 13            
23       | 2              | 4              | 9             
24       | 1              | 2              | 5             
25       | 0              | 0              | 1             
26       | 3              | 6              | 13            
27       | 2              | 4              | 9             
28       | 1              | 2              | 5             
29       | 0              | 0              | 1             
30       | 2              | 4              | 9             
31       | 3              | 6              | 13            

Distribution across 16 shards:
Shard 0 : 3 keys
Shard 1 : 5 keys
Shard 2 : 0 keys
Shard 3 : 0 keys
Shard 4 : 3 keys
Shard 5 : 5 keys
Shard 6 : 0 keys
Shard 7 : 0 keys
Shard 8 : 2 keys
Shard 9 : 6 keys
Shard 10: 0 keys
Shard 11: 0 keys
Shard 12: 2 keys
Shard 13: 6 keys
Shard 14: 0 keys
Shard 15: 0 keys

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions