Cassandra Secondary Indexes (2i)
Secondary Indexes, often called "2i" (secondary index), are Cassandra's original secondary indexing implementation introduced in version 0.7 (2011). While still supported, they have significant limitations that make them unsuitable for many production workloads.
Background and History
Section titled “Background and History”Origins
Section titled “Origins”Secondary indexes were introduced in Cassandra 0.7 to address the limitation that queries could only filter on partition keys. The implementation used a simple approach: create hidden tables that map indexed column values to partition keys.
This design was straightforward to implement but created architectural challenges that became apparent at scale. The fundamental issue is that secondary index data is distributed differently than base table data.
Design Goals
Section titled “Design Goals”The original secondary index design prioritized:
- Simplicity: Reuse existing table storage mechanisms
- Consistency: Maintain index atomically with base table writes
- Query Flexibility: Enable queries on non-primary-key columns
Design Trade-offs
Section titled “Design Trade-offs”The implementation made trade-offs that limit secondary index utility:
- Local-only indexes: Each node indexes only its local data
- Separate compaction: Index tables compact independently from base tables
- No range queries: Only equality predicates supported
- Scatter-gather queries: Queries must contact all nodes
Architecture
Section titled “Architecture”Storage Implementation
Section titled “Storage Implementation”Secondary indexes store data in hidden tables within the same keyspace as the base table:
Key characteristics:
- Index table name:
.<table>_<column>_idx - Partition key: indexed column value
- Clustering column: base table partition key
- Each node only indexes rows it owns
On-Disk File Structure
Section titled “On-Disk File Structure”Secondary indexes create a separate hidden table directory with its own SSTable files:
data/└── keyspace/ ├── users-<table_id>/ # Base table │ ├── nb-1-big-Data.db │ ├── nb-1-big-Index.db │ ├── nb-1-big-Filter.db │ ├── nb-1-big-Summary.db │ ├── nb-1-big-Statistics.db │ ├── nb-1-big-CompressionInfo.db │ └── nb-1-big-TOC.txt │ └── .users.city_idx-<index_id>/ # Hidden index table ├── nb-1-big-Data.db # Index data (value → pk mappings) ├── nb-1-big-Index.db # Partition index ├── nb-1-big-Filter.db # Bloom filter ├── nb-1-big-Summary.db # Partition summary ├── nb-1-big-Statistics.db # SSTable statistics ├── nb-1-big-CompressionInfo.db # Compression metadata └── nb-1-big-TOC.txt # Table of contents| Characteristic | Description |
|---|---|
| Directory naming | .tablename.indexname-uuid (dot prefix indicates hidden table) |
| File structure | Complete SSTable set identical to regular tables |
| Compaction | Independent schedule from base table |
| Size estimation | unique_indexed_values × average_posting_list_size |
Hidden Directory Visibility
The dot-prefixed directory is hidden by default in Unix systems. Use ls -la to view hidden index tables. The nodetool tablestats command includes index table statistics.
Write Path
Section titled “Write Path”When a row is written to a table with secondary indexes:
- Write arrives at replica node
- Base table mutation and index mutation written to commit log atomically
- Both mutations applied to respective memtables
- Acknowledgment sent to client
Read Path (Query Execution)
Section titled “Read Path (Query Execution)”Secondary index queries follow a scatter-gather pattern:
Query execution steps:
- Query arrives at coordinator
- Coordinator sends query to replicas (one replica per token range for consistency level, not necessarily all nodes)
- Each contacted node queries its local index
- Each node reads matching base table rows
- Results gathered at coordinator
- Final result returned to client
Note: The coordinator may choose one replica per token range rather than contacting every node, depending on consistency level and load balancing. However, the scatter-gather scope remains broad.
Limitations
Section titled “Limitations”Scatter-Gather Overhead
Section titled “Scatter-Gather Overhead”Every secondary index query contacts multiple nodes, even if the result exists on only one node:
Query: SELECT * FROM users WHERE city = 'NYC';
Without index knowledge:- Must query ALL nodes- Each node checks local index- Results merged at coordinator
Impact:- Latency = slowest node response- Network traffic proportional to cluster size- Coordinator memory for result mergingCardinality Problems
Section titled “Cardinality Problems”Secondary indexes perform poorly at cardinality extremes:
High Cardinality (many unique values):
Index on: user_id (UUID)Problem: Index table grows as large as base table Every lookup returns ~1 row but queries all nodesResult: Worse than full table scanLow Cardinality (few unique values):
Index on: status ENUM('active', 'inactive', 'pending')Problem: Each index entry points to millions of rows 'active' → [user1, user2, user3, ..., userN]Result: Huge partitions in index table Memory pressure during queriesOptimal Cardinality: Medium cardinality works best (thousands to millions of distinct values with reasonable distribution).
No Range Queries
Section titled “No Range Queries”Secondary indexes support only equality predicates:
-- SupportedSELECT * FROM events WHERE status = 'error';
-- NOT supported (requires ALLOW FILTERING)SELECT * FROM events WHERE timestamp > '2024-01-01';SELECT * FROM events WHERE value BETWEEN 10 AND 100;Tombstone Accumulation
Section titled “Tombstone Accumulation”Deleted index entries create tombstones that persist until compaction:
1. Row deleted from base table2. Tombstone written to index table3. Index queries must skip tombstones4. gc_grace_seconds delay before removal
Impact:- Read performance degrades with deletes- "Tombstone overwhelm" errors possibleIndependent Compaction
Section titled “Independent Compaction”Index tables compact on their own schedule:
Base table: Compacted, old data removedIndex table: May still reference deleted rows
Result:- Index returns stale partition keys- Base table read finds nothing- Wasted I/OConfiguration
Section titled “Configuration”Creating Secondary Indexes
Section titled “Creating Secondary Indexes”-- Basic indexCREATE INDEX ON users (city);
-- Named indexCREATE INDEX users_city_idx ON users (city);
-- Index on collection valuesCREATE INDEX ON users (VALUES(tags));
-- Index on collection keysCREATE INDEX ON users (KEYS(attributes));
-- Index on collection entriesCREATE INDEX ON users (ENTRIES(attributes));
-- Index on frozen collectionCREATE INDEX ON users (FULL(frozen_list));Index Options
Section titled “Index Options”-- Custom index class (rarely needed)CREATE CUSTOM INDEX ON users (city)USING 'org.apache.cassandra.index.internal.composites.ClusteringColumnIndex';Managing Indexes
Section titled “Managing Indexes”-- Drop indexDROP INDEX IF EXISTS users_city_idx;
-- View indexesSELECT * FROM system_schema.indexesWHERE keyspace_name = 'my_keyspace';When to Use Secondary Indexes
Section titled “When to Use Secondary Indexes”Acceptable Use Cases
Section titled “Acceptable Use Cases”| Scenario | Rationale |
|---|---|
| Low-traffic queries | Scatter-gather overhead acceptable |
| Medium cardinality columns | Reasonable index size and selectivity |
| Combined with partition key | Reduces scatter to single partition |
| Development/testing | Simpler than data model redesign |
Combined with Partition Key
Section titled “Combined with Partition Key”Secondary indexes perform well when combined with a partition key:
-- Efficient: partition key + indexed columnSELECT * FROM ordersWHERE customer_id = ? AND status = 'pending';
-- customer_id restricts to single partition-- status index filters within that partitionAvoid Secondary Indexes When
Section titled “Avoid Secondary Indexes When”| Scenario | Alternative |
|---|---|
| High-throughput queries | Denormalized table |
| Range queries needed | SAI or data model redesign |
| Very high cardinality | Denormalized table |
| Very low cardinality | Partition by the value |
| Write-heavy workload | Avoid indexes entirely |
Alternatives
Section titled “Alternatives”Denormalized Tables
Section titled “Denormalized Tables”Create a separate table optimized for the query pattern:
-- Base tableCREATE TABLE users ( user_id uuid PRIMARY KEY, name text, city text);
-- Query table (denormalized)CREATE TABLE users_by_city ( city text, user_id uuid, name text, PRIMARY KEY (city, user_id));
-- Application maintains both tablesTrade-offs:
- Additional write overhead (maintain multiple tables)
- Storage duplication
- Guaranteed query performance
- No scatter-gather
Materialized Views
Section titled “Materialized Views”Automatic denormalization managed by Cassandra:
CREATE MATERIALIZED VIEW users_by_city AS SELECT * FROM users WHERE city IS NOT NULL PRIMARY KEY (city, user_id);See Materialized Views for details and limitations.
Modern Index Types
Section titled “Modern Index Types”For Cassandra 3.4+, consider:
- SASI: Range queries, text search (experimental)
- SAI: Recommended for Cassandra 5.0+ (production-ready)
Monitoring
Section titled “Monitoring”Index Size and Health
Section titled “Index Size and Health”# Table statistics including indexnodetool tablestats keyspace.table
# Compaction statisticsnodetool compactionstats
# SSTable distributionnodetool cfstats keyspace.tableJMX Metrics
Section titled “JMX Metrics”# Index memory usageorg.apache.cassandra.metrics:type=Index,scope=*,name=*
# Per-table index metricsorg.apache.cassandra.metrics:type=Table,keyspace=*,scope=*,name=IndexSummaryOffHeapMemoryUsedWarning Signs
Section titled “Warning Signs”| Symptom | Likely Cause | Action |
|---|---|---|
| Slow index queries | High cardinality or tombstones | Review data model |
| Growing latency | Index size growth | Check compaction |
| Timeout errors | Scatter-gather overload | Reduce query frequency |
| Tombstone warnings | High delete rate | Adjust gc_grace or data model |
Related Documentation
Section titled “Related Documentation”- Index Overview - Index type comparison
- SASI - SSTable Attached Secondary Index
- SAI - Storage Attached Index
- Materialized Views - Automatic denormalization
- Compaction - Index table compaction