首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >记一次分库分表、读写分离架构下的数据一致性“幽灵”Bug 排查

记一次分库分表、读写分离架构下的数据一致性“幽灵”Bug 排查

原创
作者头像
七条猫
发布2025-09-24 16:24:01
发布2025-09-24 16:24:01
10720
代码可运行
举报
运行总次数:0
代码可运行

那是一个看似平常的周二下午,正当我准备冲杯咖啡摸鱼时,运营同学火急火燎地在群里 @ 我:“系统出 Bug 了!我刚给一个用户修改了会员等级,后台显示成功了,但刷新一下页面,用户的等级还是旧的!过几分钟再看,又变过来了!用户投诉说我们系统有问题!”

听到“时好时坏”、“过几分钟又好了”这类描述,我的心头一紧,直觉告诉我,这绝对不是一个简单的 Bug。一个关于数据一致性的“幽灵”Bug,就这样拉开了它在我们系统中的“表演”序幕。

一、背景与技术环境

首先,简单介绍一下我们项目的技术架构。这是一个高并发的电商系统,为了应对海量的用户请求和数据存储压力,我们采用了一套相对主流的后端架构。

技术栈/组件

说明

编程语言

Java (Spring Boot)

数据库

MySQL 8.0

架构特点

分库分表 + 读写分离

分库分表方案

基于用户 ID 进行 Hash 取模,分为 8 个库,每个库 16 张表

读写分离方案

主库(Master)负责写操作,从库(Slave)负责读操作,主从之间采用异步复制

这套架构在平时表现优异,读写分离极大地分摊了数据库压力。但正是这个“异步复制”,为我们今天的“幽灵”Bug 埋下了最深的伏笔。

二、Bug 现象:消失的更新

我让运营同学详细描述了操作过程,并亲自复现了一遍:

  1. 操作:在后台管理系统中,将用户 A (ID: 12345) 的会员等级从 LV1 修改为 LV2
  2. 系统响应:后端接口返回成功,页面弹出“修改成功”的提示。
  3. 诡异现象立即刷新用户详情页,页面上显示的会员等级依然是 LV1
  4. 自我恢复:等待大约 3-5 秒后,再次刷新页面,会员等级终于变成了 LV2

这个现象非常典型:写操作成功了,但读操作在短时间内读到了旧数据(脏数据)

三、排查之旅:从索引到主从延迟

面对这种问题,我的脑海里迅速过了一遍可能的“嫌疑人”。

第一站:前端缓存?—— 排除

最先怀疑的是不是前端搞的鬼?比如浏览器缓存或者 Vuex/Redux 状态没更新。我直接打开 Chrome 的开发者工具,勾选 Disable cache,然后监控网络请求。结果发现,每次刷新页面,前端都确实向后端发起了新的数据请求,并且后端返回的就是旧数据。

结论:前端是清白的。问题在后端。

第二站:应用层缓存?—— 排除

接下来怀疑的是后端应用层的缓存,比如 Redis。我检查了相关代码,发现用户详情这种访问频繁的数据,确实加了 Redis 缓存。但是,我们的缓存更新策略是 Cache-Aside Pattern(旁路缓存模式),即:

  1. 读操作:先读缓存,缓存没有再读数据库,然后把结果写回缓存。
  2. 写操作先更新数据库,然后直接删除缓存

理论上,更新操作会把缓存删掉,下一次读请求会穿透到数据库,拿到最新数据。我通过日志和 Redis 监控确认,更新操作后,对应的缓存 Key 确实被 DEL 了。

结论:应用层缓存逻辑没问题。问题出在“穿透到数据库”这一步。

第三站:数据库索引跑偏了?—— 走入弯路

既然缓存没问题,那就是数据库本身的问题了。此时,一个同事提出了一个猜想:“会不会是读请求的 SQL 走了错误的数据库索引,导致查询性能极差,不知怎么就读到旧数据了?”

虽然听起来有点玄学,但死马当活马医。我们定位到查询用户详情的 SQL,在从库上执行 EXPLAIN

代码语言:actionscript
复制
EXPLAIN SELECT * FROM user_info_03 WHERE user_id = 12345;
1.

结果发现 user_id 字段上确实有主键索引,typeconst,性能顶呱呱。为了彻底打消疑虑,我们甚至 force index 强制指定索引,结果依旧。

结论:数据库索引不是罪魁祸首。这次我们走了一小段弯路,但也因此更加确信,问题不在于查询性能,而在于数据本身。

第四站:真相大白 —— 读写分离与主从延迟

排除了所有不可能,剩下的,无论多么难以置信,那都是真相。

问题聚焦到了我们的读写分离架构上。整个数据流是这样的:

  1. 写操作UPDATE user_info... 请求被路由到主库 (Master) 并成功执行。此时,主库的数据是 LV2
  2. 删缓存:应用层代码执行 DEL cache_key
  3. 读操作:前端刷新,SELECT * FROM user_info... 请求因为是读操作,被路由到从库 (Slave)
  4. 关键点:由于主从复制是异步的,主库的 binlog 还没来得及同步到从库,此时从库里存储的用户等级依然是 LV1
  5. 结果:从库返回了旧数据 LV1 给应用,应用又把这个旧数据写回了 Redis 缓存。
  6. 自我恢复:几秒后,主从同步完成,从库数据更新为 LV2。如果此时缓存刚好失效或被其他操作清除了,下一次读请求就能拿到正确数据。

这个过程完美解释了所有现象。这个 Bug 的根源,在于读写分离架构破坏了数据严格的实时一致性,这是对数据库 ACID 特性中“一致性 (Consistency)”在架构层面上的一种挑战。数据库本身在单机上保证了 ACID,但由主从复制引入的系统级延迟,导致了最终用户感知到的不一致。

四、解决方案:强制主库读取与 ACID 的再思考

既然定位了问题,解决方案也就清晰了:对于那些对数据一致性要求极高的场景,我们需要绕过读写分离,强制从主库读取数据。

我们讨论了几种方案:

方案

优点

缺点

方案一:所有读都走主库

简单粗暴,永绝后患

放弃了读写分离的优势,主库压力倍增,不可取

方案二:写后延迟N秒再删缓存

实现简单

N 值难以确定,治标不治本,依然有失败概率

方案三:写后读主库,并更新缓存

逻辑清晰,能保证数据正确

增加了写操作的复杂度和耗时

方案四:代码层面实现动态路由

灵活,按需选择,影响范围最小

需要在代码层面(或中间件)做改造

我们最终选择了方案四,因为它最优雅,对系统的侵入性也控制在合理范围内。我们通过 AOP(面向切面编程)实现了一个自定义注解 @MasterRead

解决方案代码示例 (伪代码):

代码语言:javascript
代码运行次数:0
运行
复制
// 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 现象:消失的更新

我让运营同学详细描述了操作过程,并亲自复现了一遍:

  1. 操作:在后台管理系统中,将用户 A (ID: 12345) 的会员等级从 LV1 修改为 LV2
  2. 系统响应:后端接口返回成功,页面弹出“修改成功”的提示。
  3. 诡异现象立即刷新用户详情页,页面上显示的会员等级依然是 LV1
  4. 自我恢复:等待大约 3-5 秒后,再次刷新页面,会员等级终于变成了 LV2

这个现象非常典型:写操作成功了,但读操作在短时间内读到了旧数据(脏数据)

三、排查之旅:从索引到主从延迟

面对这种问题,我的脑海里迅速过了一遍可能的“嫌疑人”。

第一站:前端缓存?—— 排除

最先怀疑的是不是前端搞的鬼?比如浏览器缓存或者 Vuex/Redux 状态没更新。我直接打开 Chrome 的开发者工具,勾选 Disable cache,然后监控网络请求。结果发现,每次刷新页面,前端都确实向后端发起了新的数据请求,并且后端返回的就是旧数据。

结论:前端是清白的。问题在后端。

第二站:应用层缓存?—— 排除

接下来怀疑的是后端应用层的缓存,比如 Redis。我检查了相关代码,发现用户详情这种访问频繁的数据,确实加了 Redis 缓存。但是,我们的缓存更新策略是 Cache-Aside Pattern(旁路缓存模式),即:

  1. 读操作:先读缓存,缓存没有再读数据库,然后把结果写回缓存。
  2. 写操作先更新数据库,然后直接删除缓存

理论上,更新操作会把缓存删掉,下一次读请求会穿透到数据库,拿到最新数据。我通过日志和 Redis 监控确认,更新操作后,对应的缓存 Key 确实被 DEL 了。

结论:应用层缓存逻辑没问题。问题出在“穿透到数据库”这一步。

第三站:数据库索引跑偏了?—— 走入弯路

既然缓存没问题,那就是数据库本身的问题了。此时,一个同事提出了一个猜想:“会不会是读请求的 SQL 走了错误的数据库索引,导致查询性能极差,不知怎么就读到旧数据了?”

虽然听起来有点玄学,但死马当活马医。我们定位到查询用户详情的 SQL,在从库上执行 EXPLAIN

代码语言:javascript
代码运行次数:0
运行
复制
EXPLAIN SELECT * FROM user_info_03 WHERE user_id = 12345;
1.

结果发现 user_id 字段上确实有主键索引,typeconst,性能顶呱呱。为了彻底打消疑虑,我们甚至 force index 强制指定索引,结果依旧。

结论:数据库索引不是罪魁祸首。这次我们走了一小段弯路,但也因此更加确信,问题不在于查询性能,而在于数据本身。

第四站:真相大白 —— 读写分离与主从延迟

排除了所有不可能,剩下的,无论多么难以置信,那都是真相。

问题聚焦到了我们的读写分离架构上。整个数据流是这样的:

  1. 写操作UPDATE user_info... 请求被路由到主库 (Master) 并成功执行。此时,主库的数据是 LV2
  2. 删缓存:应用层代码执行 DEL cache_key
  3. 读操作:前端刷新,SELECT * FROM user_info... 请求因为是读操作,被路由到从库 (Slave)
  4. 关键点:由于主从复制是异步的,主库的 binlog 还没来得及同步到从库,此时从库里存储的用户等级依然是 LV1
  5. 结果:从库返回了旧数据 LV1 给应用,应用又把这个旧数据写回了 Redis 缓存。
  6. 自我恢复:几秒后,主从同步完成,从库数据更新为 LV2。如果此时缓存刚好失效或被其他操作清除了,下一次读请求就能拿到正确数据。

这个过程完美解释了所有现象。这个 Bug 的根源,在于读写分离架构破坏了数据严格的实时一致性,这是对数据库 ACID 特性中“一致性 (Consistency)”在架构层面上的一种挑战。数据库本身在单机上保证了 ACID,但由主从复制引入的系统级延迟,导致了最终用户感知到的不一致。

四、解决方案:强制主库读取与 ACID 的再思考

既然定位了问题,解决方案也就清晰了:对于那些对数据一致性要求极高的场景,我们需要绕过读写分离,强制从主库读取数据。

我们讨论了几种方案:

方案

优点

缺点

方案一:所有读都走主库

简单粗暴,永绝后患

放弃了读写分离的优势,主库压力倍增,不可取

方案二:写后延迟N秒再删缓存

实现简单

N 值难以确定,治标不治本,依然有失败概率

方案三:写后读主库,并更新缓存

逻辑清晰,能保证数据正确

增加了写操作的复杂度和耗时

方案四:代码层面实现动态路由

灵活,按需选择,影响范围最小

需要在代码层面(或中间件)做改造

我们最终选择了方案四,因为它最优雅,对系统的侵入性也控制在合理范围内。我们通过 AOP(面向切面编程)实现了一个自定义注解 @MasterRead

解决方案代码示例 (伪代码):

代码语言:javascript
代码运行次数:0
运行
复制
// 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 现象:消失的更新

我让运营同学详细描述了操作过程,并亲自复现了一遍:

  1. 操作:在后台管理系统中,将用户 A (ID: 12345) 的会员等级从 LV1 修改为 LV2
  2. 系统响应:后端接口返回成功,页面弹出“修改成功”的提示。
  3. 诡异现象立即刷新用户详情页,页面上显示的会员等级依然是 LV1
  4. 自我恢复:等待大约 3-5 秒后,再次刷新页面,会员等级终于变成了 LV2

这个现象非常典型:写操作成功了,但读操作在短时间内读到了旧数据(脏数据)

三、排查之旅:从索引到主从延迟

面对这种问题,我的脑海里迅速过了一遍可能的“嫌疑人”。

第一站:前端缓存?—— 排除

最先怀疑的是不是前端搞的鬼?比如浏览器缓存或者 Vuex/Redux 状态没更新。我直接打开 Chrome 的开发者工具,勾选 Disable cache,然后监控网络请求。结果发现,每次刷新页面,前端都确实向后端发起了新的数据请求,并且后端返回的就是旧数据。

结论:前端是清白的。问题在后端。

第二站:应用层缓存?—— 排除

接下来怀疑的是后端应用层的缓存,比如 Redis。我检查了相关代码,发现用户详情这种访问频繁的数据,确实加了 Redis 缓存。但是,我们的缓存更新策略是 Cache-Aside Pattern(旁路缓存模式),即:

  1. 读操作:先读缓存,缓存没有再读数据库,然后把结果写回缓存。
  2. 写操作先更新数据库,然后直接删除缓存

理论上,更新操作会把缓存删掉,下一次读请求会穿透到数据库,拿到最新数据。我通过日志和 Redis 监控确认,更新操作后,对应的缓存 Key 确实被 DEL 了。

结论:应用层缓存逻辑没问题。问题出在“穿透到数据库”这一步。

第三站:数据库索引跑偏了?—— 走入弯路

既然缓存没问题,那就是数据库本身的问题了。此时,一个同事提出了一个猜想:“会不会是读请求的 SQL 走了错误的数据库索引,导致查询性能极差,不知怎么就读到旧数据了?”

虽然听起来有点玄学,但死马当活马医。我们定位到查询用户详情的 SQL,在从库上执行 EXPLAIN

代码语言:javascript
代码运行次数:0
运行
复制
EXPLAIN SELECT * FROM user_info_03 WHERE user_id = 12345;
1.

结果发现 user_id 字段上确实有主键索引,typeconst,性能顶呱呱。为了彻底打消疑虑,我们甚至 force index 强制指定索引,结果依旧。

结论:数据库索引不是罪魁祸首。这次我们走了一小段弯路,但也因此更加确信,问题不在于查询性能,而在于数据本身。

第四站:真相大白 —— 读写分离与主从延迟

排除了所有不可能,剩下的,无论多么难以置信,那都是真相。

问题聚焦到了我们的读写分离架构上。整个数据流是这样的:

  1. 写操作UPDATE user_info... 请求被路由到主库 (Master) 并成功执行。此时,主库的数据是 LV2
  2. 删缓存:应用层代码执行 DEL cache_key
  3. 读操作:前端刷新,SELECT * FROM user_info... 请求因为是读操作,被路由到从库 (Slave)
  4. 关键点:由于主从复制是异步的,主库的 binlog 还没来得及同步到从库,此时从库里存储的用户等级依然是 LV1
  5. 结果:从库返回了旧数据 LV1 给应用,应用又把这个旧数据写回了 Redis 缓存。
  6. 自我恢复:几秒后,主从同步完成,从库数据更新为 LV2。如果此时缓存刚好失效或被其他操作清除了,下一次读请求就能拿到正确数据。

这个过程完美解释了所有现象。这个 Bug 的根源,在于读写分离架构破坏了数据严格的实时一致性,这是对数据库 ACID 特性中“一致性 (Consistency)”在架构层面上的一种挑战。数据库本身在单机上保证了 ACID,但由主从复制引入的系统级延迟,导致了最终用户感知到的不一致。

四、解决方案:强制主库读取与 ACID 的再思考

既然定位了问题,解决方案也就清晰了:对于那些对数据一致性要求极高的场景,我们需要绕过读写分离,强制从主库读取数据。

我们讨论了几种方案:

方案

优点

缺点

方案一:所有读都走主库

简单粗暴,永绝后患

放弃了读写分离的优势,主库压力倍增,不可取

方案二:写后延迟N秒再删缓存

实现简单

N 值难以确定,治标不治本,依然有失败概率

方案三:写后读主库,并更新缓存

逻辑清晰,能保证数据正确

增加了写操作的复杂度和耗时

方案四:代码层面实现动态路由

灵活,按需选择,影响范围最小

需要在代码层面(或中间件)做改造

我们最终选择了方案四,因为它最优雅,对系统的侵入性也控制在合理范围内。我们通过 AOP(面向切面编程)实现了一个自定义注解 @MasterRead

解决方案代码示例 (伪代码):

代码语言:javascript
代码运行次数:0
运行
复制
// 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. 定义一个注解

代码语言:javascript
代码运行次数:0
运行
复制
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterRead {
}

// 2. 创建一个 AOP 切面,拦截所有带 @MasterRead 注解的方法

代码语言:javascript
代码运行次数:0
运行
复制
@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. 在需要实时一致性的读操作方法上,加上注解

代码语言:javascript
代码运行次数:0
运行
复制
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 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、背景与技术环境
  • 二、Bug 现象:消失的更新
  • 三、排查之旅:从索引到主从延迟
    • 第一站:前端缓存?—— 排除
    • 第二站:应用层缓存?—— 排除
    • 第三站:数据库索引跑偏了?—— 走入弯路
    • 第四站:真相大白 —— 读写分离与主从延迟
  • 四、解决方案:强制主库读取与 ACID 的再思考
  • 一、背景与技术环境
  • 二、Bug 现象:消失的更新
  • 三、排查之旅:从索引到主从延迟
    • 第一站:前端缓存?—— 排除
    • 第二站:应用层缓存?—— 排除
    • 第三站:数据库索引跑偏了?—— 走入弯路
    • 第四站:真相大白 —— 读写分离与主从延迟
  • 四、解决方案:强制主库读取与 ACID 的再思考
  • 一、背景与技术环境
  • 二、Bug 现象:消失的更新
  • 三、排查之旅:从索引到主从延迟
    • 第一站:前端缓存?—— 排除
    • 第二站:应用层缓存?—— 排除
    • 第三站:数据库索引跑偏了?—— 走入弯路
    • 第四站:真相大白 —— 读写分离与主从延迟
  • 四、解决方案:强制主库读取与 ACID 的再思考
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档