Alias Routing and Multi-Index Search Patterns
Alias Routing and Multi-Index Search Patterns
The Symptom
The documentation platform serves 50 tenants from a shared index. Each search request includes a tenant_id filter. A query for Tenant A scans documents across all shards, even though Tenant A’s documents only reside on 2 of the 10 shards (due to custom routing). The other 8 shards return zero results but still consume search thread pool time and network bandwidth.
The Internals
Filtered aliases combine alias resolution with a query filter. When a search targets a filtered alias, OpenSearch automatically appends the filter to the query. For multi-tenant indices, this means the tenant filter is applied at the alias level, not at the application level.
Routing aliases combine alias resolution with routing. When a search targets a routing alias, OpenSearch routes the search to only the shards that contain documents for that routing value. For multi-tenant indices with custom routing on tenant_id, this means a search for Tenant A only hits the shards that contain Tenant A’s documents.
Combining both: a filtered alias with routing provides both data isolation (only Tenant A’s documents) and shard targeting (only Tenant A’s shards).
The Implementation
Filtered and Routed Aliases
// HARDENED: Each tenant gets a filtered, routed alias
// Search isolation and shard targeting in one configuration
public void createTenantAlias(String tenantId) throws IOException {
String index = "docs-shared-v1";
String alias = "docs-" + tenantId;
client.indices().updateAliases(ua -> ua
.actions(a -> a.add(ad -> ad
.index(index)
.alias(alias)
.filter(q -> q.term(t -> t
.field("tenant_id")
.value(tenantId)
))
.routing(tenantId) // Index routing
.searchRouting(tenantId) // Search routing
))
);
}
// Application search code uses the tenant alias directly
// No tenant_id filter needed in the query—the alias provides it
public SearchResponse<DocPage> searchTenantDocs(String tenantId,
String query) throws IOException {
return client.search(s -> s
.index("docs-" + tenantId) // Filtered alias with routing
.query(q -> q.multiMatch(mm -> mm
.query(query)
.fields("title^3", "body")
))
.size(10),
DocPage.class
);
}
// FRAGILE: Manual tenant filter without alias routing.
// Searches all shards even though documents are routed to specific shards.
// Every search includes the tenant filter—duplication across all search code.
public SearchResponse<DocPage> searchWithoutAlias(String tenantId,
String query) throws IOException {
return client.search(s -> s
.index("docs-shared-v1")
.query(q -> q.bool(b -> b
.filter(f -> f.term(t -> t
.field("tenant_id").value(tenantId))) // Repeated everywhere
.must(mu -> mu.multiMatch(mm -> mm
.query(query)
.fields("title^3", "body")
))
))
.size(10),
DocPage.class
);
}
Cross-Version Search
// Search across all documentation versions for a tenant
// Each version is a separate index, all added to the read alias
public void addVersionToReadAlias(String tenantId, String versionIndex)
throws IOException {
client.indices().updateAliases(ua -> ua
.actions(a -> a.add(ad -> ad
.index(versionIndex)
.alias("docs-" + tenantId + "-all-versions")
))
);
}
// Search all versions with version as a facet
public FacetedSearchResult searchAllVersions(String tenantId,
String query) throws IOException {
return client.search(s -> s
.index("docs-" + tenantId + "-all-versions")
.query(q -> q.multiMatch(mm -> mm
.query(query)
.fields("title^3", "body")
))
.aggregations("version", a -> a
.terms(t -> t.field("version").size(50))
)
.size(10),
DocPage.class
);
}
Alias-Based Rollback
// HARDENED: Instant rollback by swapping aliases back to the previous index
// No data movement, no reindexing, sub-second operation
public void rollbackToVersion(String tenantId, int currentVersion,
int rollbackVersion) throws IOException {
String currentIndex = "docs-" + tenantId + "-v" + currentVersion;
String rollbackIndex = "docs-" + tenantId + "-v" + rollbackVersion;
// Atomic swap: both read and write aliases move in one operation
client.indices().updateAliases(ua -> ua
.actions(a -> a.remove(r -> r
.index(currentIndex)
.alias("docs-" + tenantId + "-read")
))
.actions(a -> a.add(ad -> ad
.index(rollbackIndex)
.alias("docs-" + tenantId + "-read")
))
.actions(a -> a.remove(r -> r
.index(currentIndex)
.alias("docs-" + tenantId + "-write")
))
.actions(a -> a.add(ad -> ad
.index(rollbackIndex)
.alias("docs-" + tenantId + "-write")
.isWriteIndex(true)
))
);
}
The Measurement
Search latency with and without alias routing (50-tenant shared index, 10 shards):
| Access Pattern | Shards Searched | p50 Latency | p99 Latency |
|---|---|---|---|
| Direct index + filter | 10 | 22ms | 85ms |
| Filtered alias (no routing) | 10 | 20ms | 80ms |
| Filtered alias + routing | 2 | 8ms | 28ms |
Alias routing reduces the number of shards searched from 10 to 2, cutting p50 latency by 60% and p99 latency by 65%. The improvement is proportional to the ratio of total shards to tenant-specific shards.
The Decision Rule
Create filtered aliases with routing for every tenant in a shared index. The alias encapsulates both data isolation and shard targeting, eliminating the need for tenant filters in application code and reducing search latency proportionally to the number of shards.
Use alias-based rollback as the primary recovery mechanism for mapping changes. Retain the previous version’s index for at least 48 hours after a migration. Alias swaps execute in under 100ms and require no data movement—they are the fastest rollback mechanism available.
Add all version indices to a cross-version search alias. This allows “search across all docs” functionality without the application needing to know which concrete indices exist.