1. 业务背景
本篇文章主要来说说redis和mysql中的数据一致性问题。首先给出结论:不存在强一致性的完美解决方案,选择"先更新mysql在删除redis"方案是产生数据不一致的概率最低,数据丢失风险最小,把控度最高的方案。
我们在日常的项目里面,通常都会涉及把一些不怎么经常变化但是又经常访问的数据放在redis缓存中以提高读取数据的性能,如果从redis查询到数据则返回,没有查询到则从mysql中查询,然后回写到redis。这是很常规的操作,大家是再熟悉不过了。例如自己所在的智能硬件项目,经常会根据固件版本获取硬件固件信息,以此来判断此版本是否存在,这个固件信息一般上传之后很久不会变动,但是会经常的查询。如下:
/**
* 根据版本获取固件信息,来判断此版本是否存在
*/
@Test
public void testFirmware() {
FirmwareInfo firmwareInfo = firmwareService.get("0.0.1");
log.info("firmwareInfo: {}", JSON.toJSONString(firmwareInfo));
}
/**
* 根据版本号获取设备固件信息
* @param version
* @return
*/
@Override
public FirmwareInfo get(String version) {
// 从redis中获取固件信息
Object firmwareInfo = redisUtil.get(getVersionKey(version));
// 如果没有则从mysql中查询
if(Objects.isNull(firmwareInfo)) {
// 从DB查询
firmwareInfo = firmwareInfoMapper.selectByVersion(version);
// 最后回写到redis
redisUtil.set(getVersionKey(version), Objects.isNull(firmwareInfo) ? 0 : firmwareInfo, 72);
}
// 最后返回固件信息
return (Objects.isNull(firmwareInfo) || firmwareInfo.equals(0)) ? null : (FirmwareInfo)firmwareInfo;
}
但是固件信息有时候在某种情况下也是需要更新的,例如:我们的固件的下载url更新了,我们需要根据version获取新的url信息。那我们必然要更新这个新的url,更新策略是怎样的???
2. 几种更新策略分析
2.1 先更新mysql再更新redis
主要存在的问题:
多个请求并发例如请求A、B同时更新url,当请求B更新mysql出现在请求A之后,更新redis出现在请求A之前。会出现mysql中的url和redis中的url不一致。如上橙色框中所示。
如果第二步更新redis失败,会导致redis中的是旧的数据,mysql中是新的数据,mysql中的数据与redis中的数据不一致。
当A请求更新的同时,B请求同时读取固件信息。当B请求读取固件信息发生在A更新redis之前,B读取到redis中旧的url,然后A更新reids中的新的url。此时会出现一次数据不一致,但是下次B再次读取时,会读取到redis中新的url。
这个概率很大,因为请求A有更新mysql+更新redis两步操作,而请求B只有读取redis缓存一步操作。
这是一种特殊的情况,缓存刚好失效,读请求B在A请求更新mysql之前读取到mysql中的旧数据,然后A更新成功,最后B回写redis旧数据0.0.1。此时出现redis和mysql中数据不一致。但是这种情况的发生条件比较严格,需要同时满足下面的条件:
- 并发更新读取的时候,刚好redis失效
- B请求读取mysql发生在A更新mysql之前
- B请求回写redis旧数据发生在A请求更新redis之后
这3者同时满足的概率很低,几乎可以忽略不计了。
2.2 先更新redis再更新mysql
跟上面"先更新mysql再更新redis"类似,不再画图说明。主要存在的问题:
请求A、B同时更新url,当请求B的更新redis发生在请求A之后,当请求B更新mysql出现在请求A之前。会出现mysql中的url和redis中的url不一致。
如果第二步更新mysql失败,会导致mysql中的是旧的数据,redis中是新的数据,mysql中的数据与redis中的数据不一致。当redis失效之后,会读取到mysql中的旧数据。这种数据没有落库到mysql,只是存在redis中,数据丢失风险大。所以"先更新redis再更新mysql"这种方案几乎不考虑。
当A请求更新的同时,B请求同时读取固件信息。当B请求读取固件信息不管是发生在A更新mysql之前还是之后,B读取到redis中始终是新的url。此时不会出现数据不一致。
这是一种特殊的情况,读请求B在请求A更新redis之前缓存刚好失效,读请求B在A请求更新mysql之前读取到mysql中的旧数据,然后A更新成功,最后B回写redis旧数据0.0.1。此时出现redis和mysql中数据不一致。但是这种情况的发生条件比较严格,需要同时满足下面的条件:
- 读请求B在请求A更新redis之前缓存刚好失效
- B请求读取mysql发生在A更新mysql之前
- B请求回写redis旧数据发生在A请求更新mysql之后
这3者同时满足的概率很低,几乎可以忽略不计了。
2.3 先删除redis再更新mysql
redis和mysql中数据不会产生不一致问题,因为redis中的数据会被删除,只有mysql中有数据。
这个比较简单,直接说明,不再画图。更新mysql失败,则mysql中是旧的数据,由于redis中数据已经删除,所以不会产生数据不一致。但是mysql中始终是旧的数据,所以下次请求读取数据,获取到的都是旧的数据,需要要加上重试更新mysql机制。并且mysql中的数据一般来说应该要保证准确性权威性的。这种方案"先删除redis再更新mysql"一般来说也不可取。
如上图,当读请求B在写请求A执行更新mysql之前,读取到了mysql中的旧数据url=0.0.1,然后回写redis,此时就会出现mysql中的数据和redis中的数据不一致。这个不一致需要额外采取补救措施。此时的补救措施是大家常常听到的"延迟双删"。就是写请求A在更新完mysql之后,延迟一段时间在删除redis缓存,下次读请求会从mysql中读取,然后回写redis,达到最终的一致性。如果删除失败,需要重试。整个实现比较复杂,可控性比较差。
这个方案有下面一些缺点:
- 延迟的时间不好把控,时间设置短了,删除redis操作发生在请求B把旧数据回写到redis之前,这样redis还是会缓存旧的数据;时间设置长了,数据不一致的时间窗口会很长。
- 这个延迟处理,需要借助MQ,发送MQ异步延迟消息,然后消费端延迟消费删除缓存
这是一种特殊的情况,缓存刚好失效,读请求B在A请求更新mysql之前读取到mysql中的旧数据,然后A更新成功,最后B回写redis旧数据0.0.1。此时出现redis和mysql中数据不一致。但是这种情况的发生条件比较严格,需要同时满足下面的条件:
- 并发更新读取的时候,刚好redis失效
- B请求读取mysql发生在A更新mysql之前
- B请求回写redis旧数据发生在A请求删除redis之后
这3者同时满足的概率很低,几乎可以忽略不计了。
2.4 先更新mysql再删除redis
并发更新时跟上面的"先删除redis再更新mysql"类似,redis和mysql中数据不会产生不一致问题,因为redis中的数据最终都会被删除,只有mysql中有数据。
这个比较简单,直接说明,不再画图。删除redis失败,则redis中是旧的数据,所以会产生数据不一致。可以采用处理方式:
- 异步发送MQ消息重试删除
- redis的key设置过期时间,时间到期后自动删除,达到最终一致性
对比上面的"先删除redis再更新mysql"方案,此方案只需重试删除。
当读请求B读取redis发生在写请求A删除redis之前时,B读取到redis中的旧数据,仅仅存在这1次数据不一致。后面由于A删除了redis,B读取数据从mysql读取然后回写redis,数据达到一致。同时这个数据不一致的时间窗口存在于请求A更新mysql和删除redis之间的短暂间隙时间,非常短暂,不一致的时间窗口很短。
这是一种特殊的情况,缓存刚好失效,读请求B在A请求更新mysql之前读取到mysql中的旧数据,然后A更新成功,删除redis,最后B回写redis旧数据0.0.1。此时出现redis和mysql中数据不一致。但是这种情况的发生条件比较严格,需要同时满足下面的条件:
- 并发更新读取的时候,刚好redis失效
- B请求读取mysql发生在A更新mysql之前
- B请求回写redis旧数据发生在A请求删除redis之后
这3者同时满足的概率很低,几乎可以忽略不计了。
3. 更新策略对比
更新策略 | 并发更新导致不一致 | 第二步操作失败导致不一致 | 并发更新读取导致不一致 | 缓存刚好失效&&并发更新读取导致不一致 |
---|---|---|---|---|
先更新mysql再更新redis | 存在 | 存在 | 存在一次,概率大 | 存在,概率很低 |
先更新redis再更新mysql | 存在 | 存在,且数据丢失风险大 | 不存在 | 存在,概率很低 |
先删除redis再更新mysql | 不存在 | 不存在,但数据丢失风险大,数据权威性破坏 | 存在,延迟双删时间难把控 | 存在,概率很低 |
先更新mysql再删除redis | 不存在 | 存在,重试删除即可 | 只存在1次不一致,产生不一致的时间窗口很短 | 存在,概率很低 |
4. 最后总结
- 先更新mysql再更新redis,在并发情况下,出现数据不一致的概率很大,此方案不考虑。
- 先更新redis再更新mysql ,在并发情况下,出现数据不一致的概率很大。并且如果更新mysql失败,需要引入重试机制,重试次数等都不好把控。并且新的数据存在redis缓存,mysql没有落库,数据丢失风险高。此方案也不考虑。
- 先删除redis再更新mysql,如果更新mysql失败,虽然不会导致数据不一致,但是新的数据更新没有安全落库到mysql,同时redis缓存此时也删除了,会有数据丢失的风险,mysql的数据安全权威性遭到破坏。同时并发更新读取时,会导致数据不一致,需要引入"延迟双删"机制,此机制的延迟时间也不好把控
- 所以只剩下最后的"先更新mysql再删除redis"方案。首先并发更新不会导致数据不一致,其次第二步删除redis失败重试即可实现简单好把控,最后并发更新读取时,只会存在一次数据不一致并且这个不一致的时间窗口期很短。
所以综上所述,综合考虑,选择"先更新mysql在删除redis"方案是产生数据不一致的概率最低,数据丢失风险最小,把控度最高的方案。