How to Keep Redis and MySQL Consistent? Update DB First or Delete Cache First?
This article analyzes why cache and database can become inconsistent, compares four basic cache‑update patterns, explains the delayed double‑delete technique, shows how to use MQ for retrying cache deletions, and evaluates Canal binlog subscription as a zero‑intrusion solution for strong eventual consistency.
Why Cache and Database Can Diverge
Cache consistency problems stem from the fact that Redis and MySQL are independent storage systems without a distributed transaction that can guarantee atomicity across both. Any two‑step operation (e.g., update DB then delete cache) creates a time window where concurrent reads or writes can cause stale data to be observed.
Typical Inconsistent Scenarios
Product detail page shows a price that differs from the database because the cache was not refreshed.
Concurrent updates leave the cache with an old value while the database holds the new one for minutes.
Complex consistency logic dramatically reduces cache hit rate, making performance worse than no cache.
Cache‑deletion failures leave dirty data in Redis until expiration.
Core conclusion: any two‑step operation always has a time window; under high concurrency that window can cause inconsistency. The mitigation is to shrink the window, lower the probability of failure, or use compensation mechanisms to eventually converge.
Three Levels of Consistency
Strong consistency : cache and DB are always identical (high cost, suitable for finance or inventory).
Final consistency : temporary divergence is allowed; data converges after a short period (most common).
Weak consistency : no guarantee, best‑effort only (suitable for rankings, non‑critical data).
99% of business scenarios aim for final consistency , not strong consistency, because the latter hurts performance and availability.
Four Basic Cache‑Update Schemes
Scheme 1 – Update DB then Delete Cache (Cache‑Aside, Recommended)
Read request: read cache → hit returns → miss reads DB → write cache → return
Write request: update DB → delete cacheWhy delete instead of update?
Updating cache must handle write order; later writes may overwrite earlier ones.
Cache values are often computed or aggregated, making updates costly.
Deletion is idempotent and simple.
Lazy‑load on next read avoids unnecessary writes.
Edge case: if a read thread reads the old DB value, writes it back to cache, and the write thread later updates DB and deletes cache, the cache may temporarily hold the stale value. This requires three strict conditions (cache expires just before write, read and write arrive simultaneously, and DB read is slower than write), which are rare in practice.
Scheme 2 – Delete Cache then Update DB
Write request: delete cache → [time window] → update DBProblem: the window is larger, so stale cache can persist until expiration, making inconsistency more likely.
Scheme 3 – Update DB then Update Cache
Write request: update DB → update cacheProblems:
Concurrent writes can cause out‑of‑order cache updates, leaving cache with an older value.
Write‑heavy keys waste effort updating cache that is rarely read.
Cache updates may require expensive calculations.
Scheme 4 – Update Cache then Update DB
Write request: update cache → update DBProblem: if DB update fails, the cache becomes permanently dirty. Never use this scheme.
Comparison Summary
Scheme 1 offers final consistency with a small inconsistency window and low implementation complexity, earning the highest recommendation. Scheme 2 has a larger window and lower safety. Scheme 3 suffers from write‑order issues. Scheme 4 is the worst.
Conclusion: the baseline choice is Update DB then Delete Cache (Cache‑Aside) . All other schemes have clear drawbacks.
Delayed Double Delete – Classic Fix for Concurrency
Even though Cache‑Aside’s failure probability is low, high‑consistency scenarios benefit from an extra safeguard.
What Is Delayed Double Delete?
Write request: delete cache → update DB → delay → delete cache againThe first delete forces subsequent reads to hit the DB. After the DB update, a delayed second delete removes any stale value that a concurrent read might have written back.
Why Two Deletes?
The second delete clears the old value written by a read that occurred during the DB update.
Thread A: delete cache → update DB → delay → delete cache again
Thread B: read DB (old) → write cache (old)
Thread A (after delay): second delete removes the stale cache entryChoosing the Delay
The delay must exceed the time needed for a read to fetch from DB and write back to cache. A practical range is 500 ms – 1 s, or slightly larger than the P99 read latency.
Implementation
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductMapper productMapper;
private final StringRedisTemplate redisTemplate;
private final ThreadPoolTaskScheduler taskScheduler;
private static final String CACHE_PREFIX = "product:";
private static final long DELAY_MS = 500;
public ProductVO getProductById(Long id) {
String cacheKey = CACHE_PREFIX + id;
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isNotBlank(cacheValue)) {
return JSONUtil.toBean(cacheValue, ProductVO.class);
}
ProductPO product = productMapper.selectById(id);
if (product == null) return null;
ProductVO vo = convertToVO(product);
redisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(vo), 30, TimeUnit.MINUTES);
return vo;
}
@Transactional(rollbackFor = Exception.class)
public void updateProduct(ProductUpdateDTO dto) {
String cacheKey = CACHE_PREFIX + dto.getId();
redisTemplate.delete(cacheKey); // first delete
productMapper.updateById(convertToPO(dto)); // DB update
taskScheduler.schedule(() -> {
try {
redisTemplate.delete(cacheKey);
log.info("Delayed double delete succeeded, key={}", cacheKey);
} catch (Exception e) {
log.error("Delayed double delete failed, key={}", cacheKey, e);
// fallback to MQ retry (see next section)
}
}, new Date(System.currentTimeMillis() + DELAY_MS));
}
}Precautions
Second delete must be asynchronous to avoid blocking the write response.
If the second delete fails, a retry mechanism (e.g., MQ) is required.
Delay should be long enough to cover read‑then‑write latency but not so long that the inconsistency window becomes large.
Delayed double delete reduces risk but is not a silver bullet; extreme concurrency can still cause temporary inconsistency.
MQ Asynchronous Retry – Guarding Against Delete Failures
Both Cache‑Aside and delayed double delete share a common pain point: cache‑deletion may fail due to network glitches, Redis restarts, or crashes.
Solution: send a delete‑request message to a message queue; a consumer retries until the cache entry is removed.
Architecture
Write request → update DB → delete cache
├─ success → finish
└─ failure → send MQ → consumer retries → ack on successMessage Definition
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CacheDeleteMessage implements Serializable {
private String cacheKey;
private Integer retryCount = 0;
private static final int MAX_RETRY = 5;
public boolean canRetry() { return retryCount < MAX_RETRY; }
public CacheDeleteMessage nextRetry() { this.retryCount++; return this; }
}Delete Service
@Service
@RequiredArgsConstructor
@Slf4j
public class CacheService {
private final StringRedisTemplate redisTemplate;
private final RocketMQTemplate rocketMQTemplate;
private static final String CACHE_DELETE_TOPIC = "cache-delete-topic";
public void deleteWithRetry(String cacheKey) {
try {
Boolean result = redisTemplate.delete(cacheKey);
if (Boolean.TRUE.equals(result)) {
log.debug("Cache delete succeeded, key={}", cacheKey);
return;
}
} catch (Exception e) {
log.error("Cache delete exception, sending MQ retry, key={}", cacheKey, e);
}
sendDeleteMessage(new CacheDeleteMessage(cacheKey, 0));
}
private void sendDeleteMessage(CacheDeleteMessage msg) {
try {
rocketMQTemplate.syncSend(CACHE_DELETE_TOPIC, msg);
} catch (Exception e) {
log.error("Failed to send cache delete MQ, key={}", msg.getCacheKey(), e);
// alert for manual intervention
}
}
}Consumer with Exponential Back‑off
@Component
@RequiredArgsConstructor
@Slf4j
@RocketMQMessageListener(topic = "cache-delete-topic", consumerGroup = "cache-delete-consumer-group")
public class CacheDeleteConsumer implements RocketMQListener<CacheDeleteMessage> {
private final StringRedisTemplate redisTemplate;
private final RocketMQTemplate rocketMQTemplate;
@Override
public void onMessage(CacheDeleteMessage msg) {
String key = msg.getCacheKey();
log.info("Consuming cache delete, key={}, retry={}", key, msg.getRetryCount());
try {
Boolean result = redisTemplate.delete(key);
if (Boolean.TRUE.equals(result) || result == null) {
log.info("Cache retry delete succeeded, key={}", key);
return;
}
} catch (Exception e) {
log.error("Cache retry delete exception, key={}", key, e);
}
if (msg.canRetry()) {
long delayLevel = Math.min(msg.getRetryCount() + 1, 10);
rocketMQTemplate.syncSend("cache-delete-topic",
MessageBuilder.withPayload(msg.nextRetry()).build(),
3000, (int) delayLevel);
} else {
log.error("Cache delete exceeded max retries, manual intervention, key={}", key);
// alert for manual handling
}
}
}Pros and Cons
✅ Reliable retry guarantees eventual cache removal.
✅ Asynchronous, does not affect main request latency.
✅ Supports exponential back‑off.
❌ Adds MQ dependency and operational complexity.
❌ MQ itself can fail and needs monitoring.
❌ Consistency is still eventual; a short window of stale data may exist.
Canal Binlog Subscription – The Most Thorough Final‑Consistency Solution
When many services modify the same tables or when you need zero‑intrusion cache synchronization, listening to MySQL binlog via Canal ensures that every committed change triggers a cache update.
What Is Canal?
Canal is an Alibaba‑open‑source component that acts as a MySQL slave, receives binlog events, parses them, and pushes change events to your application.
Canal connects to MySQL and subscribes to binlog.
MySQL pushes binlog entries.
Canal parses INSERT/UPDATE/DELETE events.
Your handler deletes or updates the corresponding Redis key.
Architecture
App → update MySQL → MySQL generates binlog → Canal reads binlog → App receives event → delete/update RedisAdvantages: business code never touches cache logic; consistency is guaranteed as long as binlog is captured.
Implementation Steps
Add Canal client dependency.
Create a configuration class with server, port, destination, subscription tables, batch size.
Start a listener thread that connects, subscribes, fetches batches, and forwards entries to a handler.
Handler parses entries, extracts primary key, maps table name to cache key prefix, and deletes the key.
Sample Handler
@Service
@RequiredArgsConstructor
@Slf4j
public class BinlogEventHandler {
private final StringRedisTemplate redisTemplate;
public void handle(List<CanalEntry.Entry> entries) {
for (CanalEntry.Entry entry : entries) {
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) continue;
String table = entry.getHeader().getTableName();
CanalEntry.RowChange rc;
try { rc = CanalEntry.RowChange.parseFrom(entry.getStoreValue()); }
catch (InvalidProtocolBufferException e) { log.error("Parse error", e); continue; }
if (rc.getEventType() != CanalEntry.EventType.INSERT &&
rc.getEventType() != CanalEntry.EventType.UPDATE &&
rc.getEventType() != CanalEntry.EventType.DELETE) continue;
for (CanalEntry.RowData row : rc.getRowDatasList()) {
List<CanalEntry.Column> cols = rc.getEventType() == CanalEntry.EventType.INSERT ?
row.getAfterColumnsList() : row.getBeforeColumnsList();
String id = cols.stream()
.filter(CanalEntry.Column::getIsKey)
.findFirst()
.map(CanalEntry.Column::getValue)
.orElse(null);
if (StrUtil.isBlank(id)) { log.warn("No PK for table {}", table); continue; }
String cacheKey = switch (table) {
case "product" -> "product:" + id;
case "user" -> "user:" + id;
case "order" -> "order:" + id;
default -> { log.warn("No cache mapping for table {}", table); yield null; }
};
if (StrUtil.isNotBlank(cacheKey)) {
redisTemplate.delete(cacheKey);
log.info("Canal deleted cache key {} for table {}", cacheKey, table);
}
}
}
}
}Pros and Cons
✅ Zero business‑code intrusion.
✅ High reliability – every committed DB change is captured.
✅ Centralized cache maintenance.
✅ Works across multiple services.
❌ Requires deploying and maintaining Canal.
❌ Adds latency (hundreds of ms to seconds).
❌ Needs MySQL binlog (ROW mode) enabled and high‑availability setup for Canal.
❌ Still only eventual consistency.
Choosing the Right Scheme for Your Business
Based on consistency requirements and system complexity, three tiers are recommended:
Tier 1 – Ordinary Business (Cache‑Aside + TTL)
Suitable for product details, user profiles, configuration data. Read from cache first, fall back to DB, write DB then delete cache, and set a reasonable TTL (e.g., 30 min). This covers ~90 % of cases with minimal risk.
Tier 2 – Higher Consistency (Cache‑Aside + Delayed Double Delete + MQ Retry)
For order status, inventory display, price information. Add delayed double delete to shrink the inconsistency window, and use MQ retry to guarantee cache deletion even when Redis is temporarily unavailable.
Tier 3 – Stronger Guarantees or Multi‑App Sharing (Canal Binlog)
When many services modify the same tables or when you cannot rely on each service to delete cache correctly, Canal provides a zero‑intrusion, reliable eventual‑consistency mechanism.
Important reminder: for truly strong‑consistency data such as inventory or account balance, avoid cache‑based consistency altogether. Read directly from the database or use distributed locks/Redis atomic operations.
Full Summary
Cache‑DB inconsistency originates from the lack of a distributed transaction between Redis and MySQL. The simplest and most widely adopted solution is the Cache‑Aside pattern (update DB then delete cache). Its failure probability is low, but edge cases exist under extreme concurrency. Delayed double delete further reduces that risk, while MQ retry ensures that cache‑deletion failures are eventually resolved. Canal binlog subscription offers the most thorough, zero‑intrusion approach at the cost of additional infrastructure and latency. There is no universal “best” solution; the optimal choice balances consistency, performance, and complexity according to the specific business scenario.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
