Redis Cache
The Redis row cache keeps a copy of entity rows in Redis so that GetByID and GetByIDs can skip MySQL. It is an invalidate-on-write cache: writes only delete keys, reads fill them. This page also covers the closely related cached unique index lookups.
Enabling the row cache
Tag the ID field:
type UserEntity struct {
ID uint64 `orm:"redisCache"` // default Redis pool
// ...
}
type LogEntity struct {
ID uint64 `orm:"redisCache=cache"` // Redis pool "cache"
// ...
}
redisCache alone uses fluxaorm.DefaultPoolCode; redisCache=<pool> a pool registered with registry.RegisterRedis(...). An unknown pool fails validation with redis pool '<pool>' not found. See Data Pools.
TTL is fixed
Cached rows and cached unique index keys always expire after fluxaorm.EntityCacheTTL, which is time.Hour. An orm:"ttl=N" tag is parsed and copied into the provider but is not used by any read path — there is currently no way to change the TTL. Correctness comes from invalidation; the TTL is only a backstop for a lost invalidation.
How rows are stored
Every cached row is a Redis list under the key <prefix><id>:
prefixis the first 8 hex characters ofsha256(<mysql pool code if not "default"> + <table name>)followed by:(for examplee11dd246:). It depends on the table only, so two versions of your binary in a rolling deploy share one key space and see each other's invalidations. The provider exposes it asRedisCachePrefix().- element
0is the stamp: the first 16 hex characters ofsha256of the column names joined by:. It changes whenever a column is added, removed or renamed. A list whose stamp differs from the running binary's is treated as a miss and rewritten, so a schema change never makes old cache entries be read positionally into the wrong fields. - elements
1..Nare the column values in column order, as strings:NULLis"", booleans are"1"/"0", floats are formatted with the column's precision, times and dates are Unix seconds,Referencesare their JSON array.
A row that does not exist is cached as a list with the single element "" (negative entry), so repeated lookups of a missing id do not hit MySQL either.
Reads:
GetByID:LRANGE key 0 -1. Negative entry ⇒ not found. Stamp match ⇒ entity built from the list. OtherwiseSELECTfrom MySQL and rewrite the key with a Lua script (fluxaorm.RowCacheRewriteScript:DEL,RPUSH,EXPIREin one step, so concurrent fillers leave exactly one list).GetByIDs: one pipelinedLRANGEper id not already in the context cache, oneSELECT ... WHERE ID IN (...)for the misses, then a pipelinedDEL/RPUSH/EXPIREper loaded row and a negative entry per missing id.Search*methods select ids in MySQL and hydrate throughGetByIDs, so the row cache serves them too; ids and search results themselves are never cached.Reloadnever reads the row cache.
Inside a transaction the row cache is bypassed entirely: no reads, no fills. The transaction must see its own uncommitted rows, and a fill from a pre-commit snapshot would go stale on rollback.
Invalidation
Rows are never written back to Redis after a write. Instead every INSERT, UPDATE and DELETE generated by the entity registers the row key (and the cached unique index keys, see below) with ctx.InvalidateCacheKey(pool, key), and ctx.Save deletes the registered keys twice:
- right before the SQL statement runs;
- after the rows are durable — immediately after the statement outside a transaction, after
COMMITinside one.
The second delete closes the race where a concurrent reader refilled the key from a pre-commit snapshot between the first delete and the commit. On rollback the pending keys are discarded. The next GetByID repopulates the key from MySQL.
You can use the same mechanism for keys of your own that depend on entity data:
InvalidateCacheKey(pool, key string)
Keys passed to ctx.InvalidateCacheKey are deleted by the next ctx.Save on that context with the same before/after timing. Nothing happens until a Save runs.
Clearing a whole table
deleted, err := entities.UserEntityProvider.ClearRedisCache(ctx)
ClearRedisCache(ctx) (int, error) runs a Lua script that SCANs the pool for <prefix>* and UNLINKs every match — row entries, negative entries and cached unique index keys — and returns how many keys were removed. It scans the whole keyspace of the pool, so treat it as a maintenance operation (after a manual data fix, in tests), not something to call per request. Every provider whose entity has redisCache or CachedUniqueIndexes() implements fluxaorm.RedisCacheEntityProvider, so all caches can be cleared in one loop:
for _, provider := range entities.AllProviders {
if cached, ok := provider.(fluxaorm.RedisCacheEntityProvider); ok {
if _, err := cached.ClearRedisCache(ctx); err != nil {
return err
}
}
}
Cached unique indexes
A unique index lookup (WHERE Code = ?) can be cached too: the cache maps the looked-up values to the row id, and the row itself then comes from the row cache or MySQL. Declare which unique indexes to cache with CachedUniqueIndexes(); every entry must also appear in UniqueIndexes() (see MySQL Indexes), otherwise validation fails with cached unique index '<name>' in entity '<type>' is not defined in UniqueIndexes().
type CategoryEntity struct {
ID uint64 `orm:"redisCache"`
Code string `orm:"required;length=10"`
Name string `orm:"required;length=100"`
}
func (e CategoryEntity) UniqueIndexes() [][]string {
return [][]string{{"Code"}}
}
func (e CategoryEntity) CachedUniqueIndexes() [][]string {
return [][]string{{"Code"}}
}
There is no dedicated lookup method. SearchOne detects the case itself: when the query's Filter conditions are exactly one equality condition per column of a cached unique index — no more, no less — it takes the cached path:
category, found, err := entities.CategoryEntityProvider.SearchOne(ctx,
fluxaorm.NewQuery().Filter(entities.CategoryEntityProvider.Fields.Code.Is("books")),
)
- Key:
<prefix>u:<indexName>@<8 hex>:<16 hex>, whereindexNameis the index columns joined by_(Code,Name_Age), the 8 hex characters aresha256of the column list (so repointing an index to other columns changes the key), and the 16 hex characters aresha256of the looked-up values joined by\x00(fluxaorm.UniqueIndexKeySegmentandfluxaorm.UniqueIndexKeyHashbuild these). The value is the row id as a string. - On a hit, the id is loaded with
GetByIDand verified: every indexed column of the loaded row must equal the looked-up value, and on a fake-delete entityFakeDeletemust be0. A stale hit falls through to step 3. - On a miss,
SELECT ID FROM <table> WHERE <cols> = ? [AND FakeDelete = 0] LIMIT 1runs, the key isSETwithEntityCacheTTL, and the row is returned throughGetByID.
Like the row cache, this path is skipped inside a transaction (plain SQL is used instead). Writes invalidate the keys through the same double-delete: an insert invalidates the new key, an update invalidates the old and the new key when any indexed column changed, a delete or fake delete invalidates the keys of the deleted row.
Filter conditions only
The fast path is chosen from Filter(...) conditions alone. A raw FilterWhere(...), sorting or paging attached to the same query is ignored once the cached path is taken — keep unique lookups to a bare Filter.
Cached unique indexes do not require orm:"redisCache". Without it the provider still gets RedisCode(), RedisCachePrefix() and ClearRedisCache() and the keys live under the same prefix on the default Redis pool.
Key namespace collisions
Because the prefix is derived from the table name, two entities on the same MySQL pool cannot share a table name, and a row-cache prefix cannot coincide with a Redis Search prefix. registry.Validate() (and ValidateForCodeGen()) checks this and fails with redis key prefix "<prefix>" is claimed by both <table> (<kind>) and <table> (<kind>); rename one of the tables.