那是一个看似平常的周二下午,正当我准备冲杯咖啡摸鱼时,运营同学火急火燎地在群里 @ 我:“系统出 Bug 了!我刚给一个用户修改了会员等级,后台显示成功了,但刷新一下页面,用户的等级还是旧的!过几分钟再看,又变过来了!用户投诉说我们系统有问题!”
听到“时好时坏”、“过几分钟又好了”这类描述,我的心头一紧,直觉告诉我,这绝对不是一个简单的 Bug。一个关于数据一致性的“幽灵”Bug,就这样拉开了它在我们系统中的“表演”序幕。
首先,简单介绍一下我们项目的技术架构。这是一个高并发的电商系统,为了应对海量的用户请求和数据存储压力,我们采用了一套相对主流的后端架构。
技术栈/组件 | 说明 |
---|---|
编程语言 | Java (Spring Boot) |
数据库 | MySQL 8.0 |
架构特点 | 分库分表 + 读写分离 |
分库分表方案 | 基于用户 ID 进行 Hash 取模,分为 8 个库,每个库 16 张表 |
读写分离方案 | 主库(Master)负责写操作,从库(Slave)负责读操作,主从之间采用异步复制 |
这套架构在平时表现优异,读写分离极大地分摊了数据库压力。但正是这个“异步复制”,为我们今天的“幽灵”Bug 埋下了最深的伏笔。
我让运营同学详细描述了操作过程,并亲自复现了一遍:
LV1
修改为 LV2
。LV1
。LV2
。这个现象非常典型:写操作成功了,但读操作在短时间内读到了旧数据(脏数据)。
面对这种问题,我的脑海里迅速过了一遍可能的“嫌疑人”。
最先怀疑的是不是前端搞的鬼?比如浏览器缓存或者 Vuex/Redux 状态没更新。我直接打开 Chrome 的开发者工具,勾选 Disable cache
,然后监控网络请求。结果发现,每次刷新页面,前端都确实向后端发起了新的数据请求,并且后端返回的就是旧数据。
结论:前端是清白的。问题在后端。
接下来怀疑的是后端应用层的缓存,比如 Redis。我检查了相关代码,发现用户详情这种访问频繁的数据,确实加了 Redis 缓存。但是,我们的缓存更新策略是 Cache-Aside Pattern
(旁路缓存模式),即:
理论上,更新操作会把缓存删掉,下一次读请求会穿透到数据库,拿到最新数据。我通过日志和 Redis 监控确认,更新操作后,对应的缓存 Key 确实被 DEL
了。
结论:应用层缓存逻辑没问题。问题出在“穿透到数据库”这一步。
既然缓存没问题,那就是数据库本身的问题了。此时,一个同事提出了一个猜想:“会不会是读请求的 SQL 走了错误的数据库索引,导致查询性能极差,不知怎么就读到旧数据了?”
虽然听起来有点玄学,但死马当活马医。我们定位到查询用户详情的 SQL,在从库上执行 EXPLAIN
。
EXPLAIN SELECT * FROM user_info_03 WHERE user_id = 12345;
1.
结果发现 user_id
字段上确实有主键索引,type
是 const
,性能顶呱呱。为了彻底打消疑虑,我们甚至 force index
强制指定索引,结果依旧。
结论:数据库索引不是罪魁祸首。这次我们走了一小段弯路,但也因此更加确信,问题不在于查询性能,而在于数据本身。
排除了所有不可能,剩下的,无论多么难以置信,那都是真相。
问题聚焦到了我们的读写分离架构上。整个数据流是这样的:
UPDATE user_info...
请求被路由到主库 (Master) 并成功执行。此时,主库的数据是 LV2
。DEL cache_key
。SELECT * FROM user_info...
请求因为是读操作,被路由到从库 (Slave)。binlog
还没来得及同步到从库,此时从库里存储的用户等级依然是 LV1
。LV1
给应用,应用又把这个旧数据写回了 Redis 缓存。LV2
。如果此时缓存刚好失效或被其他操作清除了,下一次读请求就能拿到正确数据。这个过程完美解释了所有现象。这个 Bug 的根源,在于读写分离架构破坏了数据严格的实时一致性,这是对数据库 ACID 特性中“一致性 (Consistency)”在架构层面上的一种挑战。数据库本身在单机上保证了 ACID,但由主从复制引入的系统级延迟,导致了最终用户感知到的不一致。
既然定位了问题,解决方案也就清晰了:对于那些对数据一致性要求极高的场景,我们需要绕过读写分离,强制从主库读取数据。
我们讨论了几种方案:
方案 | 优点 | 缺点 |
---|---|---|
方案一:所有读都走主库 | 简单粗暴,永绝后患 | 放弃了读写分离的优势,主库压力倍增,不可取 |
方案二:写后延迟N秒再删缓存 | 实现简单 | N 值难以确定,治标不治本,依然有失败概率 |
方案三:写后读主库,并更新缓存 | 逻辑清晰,能保证数据正确 | 增加了写操作的复杂度和耗时 |
方案四:代码层面实现动态路由 | 灵活,按需选择,影响范围最小 | 需要在代码层面(或中间件)做改造 |
我们最终选择了方案四,因为它最优雅,对系统的侵入性也控制在合理范围内。我们通过 AOP(面向切面编程)实现了一个自定义注解 @MasterRead
。
解决方案代码示例 (伪代码):
// 1. 定义一个注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterRead {
}
// 2. 创建一个 AOP 切面,拦截所有带 @MasterRead 注解的方法
@Aspect
@Component
public class MasterReadAspect {
@Around("@annotation(com.example.annotation.MasterRead)")
public Object forceMasterRead(ProceedingJoinPoint joinPoint) throws Throwable {
try {
// 在当前线程中设置一个“强制读主库”的标志
DbContextHolder.setMasterOnly();
// 执行原始方法(此时,数据源选择逻辑会根据标志选择主库)
return joinPoint.proceed();
} finally {
// 方法执行完毕后,清理标志,恢复默认的读写分离策略
DbContextHolder.clear();
}
}
}
// 3. 在需要实时一致性的读操作方法上,加上注解
public class UserServiceImpl implements UserService {
@Override
public void updateUserLevel(Long userId, String level) {
// 1. 更新数据库(写操作,自动走主库)
userMapper.updateLevel(userId, level);
// 2. 删除缓存
redisTemplate.delete("user:" + userId);
}
@Override
@MasterRead // <-- 关键!给这个读方法加上注解
public UserInfo getUserInfo(Long userId) {
// 先读缓存
UserInfo user = redisTemplate.get("user:" + userId);
if (user == null) {
// 缓存没有,由于 @MasterRead 的作用,这里会从主库读取
user = user是一个看似平常的周二下午,正当我准备冲杯咖啡摸鱼时,运营同学火急火燎地在群里 @ 我:“系统出 Bug 了!我刚给一个用户修改了会员等级,后台显示成功了,但刷新一下页面,用户的等级还是旧的!过几分钟再看,又变过来了!用户投诉说我们系统有问题!”
听到“时好时坏”、“过几分钟又好了”这类描述,我的心头一紧,直觉告诉我,这绝对不是一个简单的 Bug。一个关于数据一致性的“幽灵”Bug,就这样拉开了它在我们系统中的“表演”序幕。
首先,简单介绍一下我们项目的技术架构。这是一个高并发的电商系统,为了应对海量的用户请求和数据存储压力,我们采用了一套相对主流的后端架构。
技术栈/组件 | 说明 |
---|---|
编程语言 | Java (Spring Boot) |
数据库 | MySQL 8.0 |
架构特点 | 分库分表 + 读写分离 |
分库分表方案 | 基于用户 ID 进行 Hash 取模,分为 8 个库,每个库 16 张表 |
读写分离方案 | 主库(Master)负责写操作,从库(Slave)负责读操作,主从之间采用异步复制 |
这套架构在平时表现优异,读写分离极大地分摊了数据库压力。但正是这个“异步复制”,为我们今天的“幽灵”Bug 埋下了最深的伏笔。
我让运营同学详细描述了操作过程,并亲自复现了一遍:
LV1
修改为 LV2
。LV1
。LV2
。这个现象非常典型:写操作成功了,但读操作在短时间内读到了旧数据(脏数据)。
面对这种问题,我的脑海里迅速过了一遍可能的“嫌疑人”。
最先怀疑的是不是前端搞的鬼?比如浏览器缓存或者 Vuex/Redux 状态没更新。我直接打开 Chrome 的开发者工具,勾选 Disable cache
,然后监控网络请求。结果发现,每次刷新页面,前端都确实向后端发起了新的数据请求,并且后端返回的就是旧数据。
结论:前端是清白的。问题在后端。
接下来怀疑的是后端应用层的缓存,比如 Redis。我检查了相关代码,发现用户详情这种访问频繁的数据,确实加了 Redis 缓存。但是,我们的缓存更新策略是 Cache-Aside Pattern
(旁路缓存模式),即:
理论上,更新操作会把缓存删掉,下一次读请求会穿透到数据库,拿到最新数据。我通过日志和 Redis 监控确认,更新操作后,对应的缓存 Key 确实被 DEL
了。
结论:应用层缓存逻辑没问题。问题出在“穿透到数据库”这一步。
既然缓存没问题,那就是数据库本身的问题了。此时,一个同事提出了一个猜想:“会不会是读请求的 SQL 走了错误的数据库索引,导致查询性能极差,不知怎么就读到旧数据了?”
虽然听起来有点玄学,但死马当活马医。我们定位到查询用户详情的 SQL,在从库上执行 EXPLAIN
。
EXPLAIN SELECT * FROM user_info_03 WHERE user_id = 12345;
1.
结果发现 user_id
字段上确实有主键索引,type
是 const
,性能顶呱呱。为了彻底打消疑虑,我们甚至 force index
强制指定索引,结果依旧。
结论:数据库索引不是罪魁祸首。这次我们走了一小段弯路,但也因此更加确信,问题不在于查询性能,而在于数据本身。
排除了所有不可能,剩下的,无论多么难以置信,那都是真相。
问题聚焦到了我们的读写分离架构上。整个数据流是这样的:
UPDATE user_info...
请求被路由到主库 (Master) 并成功执行。此时,主库的数据是 LV2
。DEL cache_key
。SELECT * FROM user_info...
请求因为是读操作,被路由到从库 (Slave)。binlog
还没来得及同步到从库,此时从库里存储的用户等级依然是 LV1
。LV1
给应用,应用又把这个旧数据写回了 Redis 缓存。LV2
。如果此时缓存刚好失效或被其他操作清除了,下一次读请求就能拿到正确数据。这个过程完美解释了所有现象。这个 Bug 的根源,在于读写分离架构破坏了数据严格的实时一致性,这是对数据库 ACID 特性中“一致性 (Consistency)”在架构层面上的一种挑战。数据库本身在单机上保证了 ACID,但由主从复制引入的系统级延迟,导致了最终用户感知到的不一致。
既然定位了问题,解决方案也就清晰了:对于那些对数据一致性要求极高的场景,我们需要绕过读写分离,强制从主库读取数据。
我们讨论了几种方案:
方案 | 优点 | 缺点 |
---|---|---|
方案一:所有读都走主库 | 简单粗暴,永绝后患 | 放弃了读写分离的优势,主库压力倍增,不可取 |
方案二:写后延迟N秒再删缓存 | 实现简单 | N 值难以确定,治标不治本,依然有失败概率 |
方案三:写后读主库,并更新缓存 | 逻辑清晰,能保证数据正确 | 增加了写操作的复杂度和耗时 |
方案四:代码层面实现动态路由 | 灵活,按需选择,影响范围最小 | 需要在代码层面(或中间件)做改造 |
我们最终选择了方案四,因为它最优雅,对系统的侵入性也控制在合理范围内。我们通过 AOP(面向切面编程)实现了一个自定义注解 @MasterRead
。
解决方案代码示例 (伪代码):
// 1. 定义一个注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterRead {
}
// 2. 创建一个 AOP 切面,拦截所有带 @MasterRead 注解的方法
@Aspect
@Component
public class MasterReadAspect {
@Around("@annotation(com.example.annotation.MasterRead)")
public Object forceMasterRead(ProceedingJoinPoint joinPoint) throws Throwable {
try {
// 在当前线程中设置一个“强制读主库”的标志
DbContextHolder.setMasterOnly();
// 执行原始方法(此时,数据源选择逻辑会根据标志选择主库)
return joinPoint.proceed();
} finally {
// 方法执行完毕后,清理标志,恢复默认的读写分离策略
DbContextHolder.clear();
}
}
}
// 3. 在需要实时一致性的读操作方法上,加上注解
public class UserServiceImpl implements UserService {
@Override
public void updateUserLevel(Long userId, String level) {
// 1. 更新数据库(写操作,自动走主库)
userMapper.updateLevel(userId, level);
// 2. 删除缓存
redisTemplate.delete("user:" + userId);
}
@Override
@MasterRead // <-- 关键!给这个读方法加上注解
public UserInfo getUserInfo(Long userId) {
// 先读缓存
UserInfo user = redisTemplate.get("user:" + userId);
if (user == null) {
// 缓存没有,由于 @MasterRead 的作用,这里会从主库读取
user = user是一个看似平常的周二下午,正当我准备冲杯咖啡摸鱼时,运营同学火急火燎地在群里 @ 我:“系统出 Bug 了!我刚给一个用户修改了会员等级,后台显示成功了,但刷新一下页面,用户的等级还是旧的!过几分钟再看,又变过来了!用户投诉说我们系统有问题!”
听到“时好时坏”、“过几分钟又好了”这类描述,我的心头一紧,直觉告诉我,这绝对不是一个简单的 Bug。一个关于数据一致性的“幽灵”Bug,就这样拉开了它在我们系统中的“表演”序幕。
首先,简单介绍一下我们项目的技术架构。这是一个高并发的电商系统,为了应对海量的用户请求和数据存储压力,我们采用了一套相对主流的后端架构。
技术栈/组件 | 说明 |
---|---|
编程语言 | Java (Spring Boot) |
数据库 | MySQL 8.0 |
架构特点 | 分库分表 + 读写分离 |
分库分表方案 | 基于用户 ID 进行 Hash 取模,分为 8 个库,每个库 16 张表 |
读写分离方案 | 主库(Master)负责写操作,从库(Slave)负责读操作,主从之间采用异步复制 |
这套架构在平时表现优异,读写分离极大地分摊了数据库压力。但正是这个“异步复制”,为我们今天的“幽灵”Bug 埋下了最深的伏笔。
我让运营同学详细描述了操作过程,并亲自复现了一遍:
LV1
修改为 LV2
。LV1
。LV2
。这个现象非常典型:写操作成功了,但读操作在短时间内读到了旧数据(脏数据)。
面对这种问题,我的脑海里迅速过了一遍可能的“嫌疑人”。
最先怀疑的是不是前端搞的鬼?比如浏览器缓存或者 Vuex/Redux 状态没更新。我直接打开 Chrome 的开发者工具,勾选 Disable cache
,然后监控网络请求。结果发现,每次刷新页面,前端都确实向后端发起了新的数据请求,并且后端返回的就是旧数据。
结论:前端是清白的。问题在后端。
接下来怀疑的是后端应用层的缓存,比如 Redis。我检查了相关代码,发现用户详情这种访问频繁的数据,确实加了 Redis 缓存。但是,我们的缓存更新策略是 Cache-Aside Pattern
(旁路缓存模式),即:
理论上,更新操作会把缓存删掉,下一次读请求会穿透到数据库,拿到最新数据。我通过日志和 Redis 监控确认,更新操作后,对应的缓存 Key 确实被 DEL
了。
结论:应用层缓存逻辑没问题。问题出在“穿透到数据库”这一步。
既然缓存没问题,那就是数据库本身的问题了。此时,一个同事提出了一个猜想:“会不会是读请求的 SQL 走了错误的数据库索引,导致查询性能极差,不知怎么就读到旧数据了?”
虽然听起来有点玄学,但死马当活马医。我们定位到查询用户详情的 SQL,在从库上执行 EXPLAIN
。
EXPLAIN SELECT * FROM user_info_03 WHERE user_id = 12345;
1.
结果发现 user_id
字段上确实有主键索引,type
是 const
,性能顶呱呱。为了彻底打消疑虑,我们甚至 force index
强制指定索引,结果依旧。
结论:数据库索引不是罪魁祸首。这次我们走了一小段弯路,但也因此更加确信,问题不在于查询性能,而在于数据本身。
排除了所有不可能,剩下的,无论多么难以置信,那都是真相。
问题聚焦到了我们的读写分离架构上。整个数据流是这样的:
UPDATE user_info...
请求被路由到主库 (Master) 并成功执行。此时,主库的数据是 LV2
。DEL cache_key
。SELECT * FROM user_info...
请求因为是读操作,被路由到从库 (Slave)。binlog
还没来得及同步到从库,此时从库里存储的用户等级依然是 LV1
。LV1
给应用,应用又把这个旧数据写回了 Redis 缓存。LV2
。如果此时缓存刚好失效或被其他操作清除了,下一次读请求就能拿到正确数据。这个过程完美解释了所有现象。这个 Bug 的根源,在于读写分离架构破坏了数据严格的实时一致性,这是对数据库 ACID 特性中“一致性 (Consistency)”在架构层面上的一种挑战。数据库本身在单机上保证了 ACID,但由主从复制引入的系统级延迟,导致了最终用户感知到的不一致。
既然定位了问题,解决方案也就清晰了:对于那些对数据一致性要求极高的场景,我们需要绕过读写分离,强制从主库读取数据。
我们讨论了几种方案:
方案 | 优点 | 缺点 |
---|---|---|
方案一:所有读都走主库 | 简单粗暴,永绝后患 | 放弃了读写分离的优势,主库压力倍增,不可取 |
方案二:写后延迟N秒再删缓存 | 实现简单 | N 值难以确定,治标不治本,依然有失败概率 |
方案三:写后读主库,并更新缓存 | 逻辑清晰,能保证数据正确 | 增加了写操作的复杂度和耗时 |
方案四:代码层面实现动态路由 | 灵活,按需选择,影响范围最小 | 需要在代码层面(或中间件)做改造 |
我们最终选择了方案四,因为它最优雅,对系统的侵入性也控制在合理范围内。我们通过 AOP(面向切面编程)实现了一个自定义注解 @MasterRead
。
解决方案代码示例 (伪代码):
// 1. 定义一个注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterRead {
}
// 2. 创建一个 AOP 切面,拦截所有带 @MasterRead 注解的方法
@Aspect
@Component
public class MasterReadAspect {
@Around("@annotation(com.example.annotation.MasterRead)")
public Object forceMasterRead(ProceedingJoinPoint joinPoint) throws Throwable {
try {
// 在当前线程中设置一个“强制读主库”的标志
DbContextHolder.setMasterOnly();
// 执行原始方法(此时,数据源选择逻辑会根据标志选择主库)
return joinPoint.proceed();
} finally {
// 方法执行完毕后,清理标志,恢复默认的读写分离策略
DbContextHolder.clear();
}
}
}
// 3. 在需要实时一致性的读操作方法上,加上注解
public class UserServiceImpl implements UserService {
@Override
public void updateUserLevel(Long userId, String level) {
// 1. 更新数据库(写操作,自动走主库)
userMapper.updateLevel(userId, level);
// 2. 删除缓存
redisTemplate.delete("user:" + userId);
}
@Override
@MasterRead // <-- 关键!给这个读方法加上注解
public UserInfo getUserInfo(Long userId) {
// 先读缓存
UserInfo user = redisTemplate.get("user:" + userId);
if (user == null) {
// 缓存没有,由于 @MasterRead 的作用,这里会从主库读取
user = user是一个看似平常的周二下午,正当我准备冲杯咖啡摸鱼时,运营同学火急火燎地在群里 @ 我:“系统出 Bug 了!我刚给一个用户修改了会员等级,后台显示成功了,但刷新一下页面,用户的等级还是旧的!过几分钟再看,又变过来了!用户投诉说我们系统有问题!”
听到“时好时坏”、“过几分钟又好了”这类描述,我的心头一紧,直觉告诉我,这绝对不是一个简单的 Bug。一个关于数据一致性的“幽灵”Bug,就这样拉开了它在我们系统中的“表演”序幕。
一、背景与技术环境
首先,简单介绍一下我们项目的技术架构。这是一个高并发的电商系统,为了应对海量的用户请求和数据存储压力,我们采用了一套相对主流的后端架构。
技术栈/组件 说明
编程语言 Java (Spring Boot)
数据库 MySQL 8.0
架构特点 分库分表 + 读写分离
分库分表方案 基于用户 ID 进行 Hash 取模,分为 8 个库,每个库 16 张表
读写分离方案 主库(Master)负责写操作,从库(Slave)负责读操作,主从之间采用异步复制
这套架构在平时表现优异,读写分离极大地分摊了数据库压力。但正是这个“异步复制”,为我们今天的“幽灵”Bug 埋下了最深的伏笔。
二、Bug 现象:消失的更新
我让运营同学详细描述了操作过程,并亲自复现了一遍:
操作:在后台管理系统中,将用户 A (ID: 12345) 的会员等级从 LV1 修改为 LV2。
系统响应:后端接口返回成功,页面弹出“修改成功”的提示。
诡异现象:立即刷新用户详情页,页面上显示的会员等级依然是 LV1。
自我恢复:等待大约 3-5 秒后,再次刷新页面,会员等级终于变成了 LV2。
这个现象非常典型:写操作成功了,但读操作在短时间内读到了旧数据(脏数据)。
三、排查之旅:从索引到主从延迟
面对这种问题,我的脑海里迅速过了一遍可能的“嫌疑人”。
第一站:前端缓存?—— 排除
最先怀疑的是不是前端搞的鬼?比如浏览器缓存或者 Vuex/Redux 状态没更新。我直接打开 Chrome 的开发者工具,勾选 Disable cache,然后监控网络请求。结果发现,每次刷新页面,前端都确实向后端发起了新的数据请求,并且后端返回的就是旧数据。
结论:前端是清白的。问题在后端。
第二站:应用层缓存?—— 排除
接下来怀疑的是后端应用层的缓存,比如 Redis。我检查了相关代码,发现用户详情这种访问频繁的数据,确实加了 Redis 缓存。但是,我们的缓存更新策略是 Cache-Aside Pattern(旁路缓存模式),即:
读操作:先读缓存,缓存没有再读数据库,然后把结果写回缓存。
写操作:先更新数据库,然后直接删除缓存。
理论上,更新操作会把缓存删掉,下一次读请求会穿透到数据库,拿到最新数据。我通过日志和 Redis 监控确认,更新操作后,对应的缓存 Key 确实被 DEL 了。
结论:应用层缓存逻辑没问题。问题出在“穿透到数据库”这一步。
第三站:数据库索引跑偏了?—— 走入弯路
既然缓存没问题,那就是数据库本身的问题了。此时,一个同事提出了一个猜想:“会不会是读请求的 SQL 走了错误的数据库索引,导致查询性能极差,不知怎么就读到旧数据了?”
虽然听起来有点玄学,但死马当活马医。我们定位到查询用户详情的 SQL,在从库上执行 EXPLAIN。
EXPLAIN SELECT * FROM user_info_03 WHERE user_id = 12345;
1.
结果发现 user_id 字段上确实有主键索引,type 是 const,性能顶呱呱。为了彻底打消疑虑,我们甚至 force index 强制指定索引,结果依旧。
结论:数据库索引不是罪魁祸首。这次我们走了一小段弯路,但也因此更加确信,问题不在于查询性能,而在于数据本身。
第四站:真相大白 —— 读写分离与主从延迟
排除了所有不可能,剩下的,无论多么难以置信,那都是真相。
问题聚焦到了我们的读写分离架构上。整个数据流是这样的:
写操作:UPDATE user_info... 请求被路由到主库 (Master) 并成功执行。此时,主库的数据是 LV2。
删缓存:应用层代码执行 DEL cache_key。
读操作:前端刷新,SELECT * FROM user_info... 请求因为是读操作,被路由到从库 (Slave)。
关键点:由于主从复制是异步的,主库的 binlog 还没来得及同步到从库,此时从库里存储的用户等级依然是 LV1。
结果:从库返回了旧数据 LV1 给应用,应用又把这个旧数据写回了 Redis 缓存。
自我恢复:几秒后,主从同步完成,从库数据更新为 LV2。如果此时缓存刚好失效或被其他操作清除了,下一次读请求就能拿到正确数据。
这个过程完美解释了所有现象。这个 Bug 的根源,在于读写分离架构破坏了数据严格的实时一致性,这是对数据库 ACID 特性中“一致性 (Consistency)”在架构层面上的一种挑战。数据库本身在单机上保证了 ACID,但由主从复制引入的系统级延迟,导致了最终用户感知到的不一致。
四、解决方案:强制主库读取与 ACID 的再思考
既然定位了问题,解决方案也就清晰了:对于那些对数据一致性要求极高的场景,我们需要绕过读写分离,强制从主库读取数据。
我们讨论了几种方案:
方案 优点 缺点
方案一:所有读都走主库 简单粗暴,永绝后患 放弃了读写分离的优势,主库压力倍增,不可取
方案二:写后延迟N秒再删缓存 实现简单 N 值难以确定,治标不治本,依然有失败概率
方案三:写后读主库,并更新缓存 逻辑清晰,能保证数据正确 增加了写操作的复杂度和耗时
方案四:代码层面实现动态路由 灵活,按需选择,影响范围最小 需要在代码层面(或中间件)做改造
我们最终选择了方案四,因为它最优雅,对系统的侵入性也控制在合理范围内。我们通过 AOP(面向切面编程)实现了一个自定义注解 @MasterRead。
解决方案代码示例 (伪代码):
// 1. 定义一个注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterRead {
}
// 2. 创建一个 AOP 切面,拦截所有带 @MasterRead 注解的方法
@Aspect
@Component
public class MasterReadAspect {
@Around("@annotation(com.example.annotation.MasterRead)")
public Object forceMasterRead(ProceedingJoinPoint joinPoint) throws Throwable {
try {
// 在当前线程中设置一个“强制读主库”的标志
DbContextHolder.setMasterOnly();
// 执行原始方法(此时,数据源选择逻辑会根据标志选择主库)
return joinPoint.proceed();
} finally {
// 方法执行完毕后,清理标志,恢复默认的读写分离策略
DbContextHolder.clear();
}
}
}
// 3. 在需要实时一致性的读操作方法上,加上注解
public class UserServiceImpl implements UserService {
@Override
public void updateUserLevel(Long userId, String level) {
// 1. 更新数据库(写操作,自动走主库)
userMapper.updateLevel(userId, level);
// 2. 删除缓存
redisTemplate.delete("user:" + userId);
}
@Override
@MasterRead // <-- 关键!给这个读方法加上注解
public UserInfo getUserInfo(Long userId) {
// 先读缓存
UserInfo user = redisTemplate.get("user:" + userId);
if (user == null) {
// 缓存没有,由于 @MasterRead 的作用,这里会从主库读取
user = user
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。