高并发读请求的常见影响
突发的高并发读请求会让数据库实例的CPU、内存、IO资源快速耗尽,查询响应时间从几十毫秒飙升到数秒,严重时会导致数据库连接池耗尽,业务接口大面积超时。如果不提前做好架构优化,仅靠升级硬件很难应对流量突增的场景。
读写分离架构的核心逻辑
读写分离的核心是将数据库的读操作和写操作分别路由到不同的实例:主库负责处理所有的写请求和事务性查询,多个从库通过主从复制同步主库的数据,专门处理普通的读请求。这样单主库的读压力会被分摊到多个从库,整体读吞吐量可以成倍提升。
主从复制的基础配置
以MySQL为例,首先需要完成主从复制的配置,主库开启二进制日志,从库配置同步主库的日志信息,具体步骤如下:
- 主库修改配置文件,开启binlog并设置server-id
- 主库创建用于同步的账号并授权
- 从库配置主库的连接信息,启动同步线程
读写请求路由实现
在应用层可以通过数据源路由的方式实现读写分离,下面是一个基于Spring Boot的简易路由示例:
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
private static final ThreadLocal<String> DATA_SOURCE_KEY = new ThreadLocal<>();
// 设置数据源key,写请求用master,读请求用slave
public static void setDataSourceKey(String key) {
DATA_SOURCE_KEY.set(key);
}
// 清除当前线程的数据源key
public static void clearDataSourceKey() {
DATA_SOURCE_KEY.remove();
}
@Override
protected Object determineCurrentLookupKey() {
return DATA_SOURCE_KEY.get();
}
// 初始化主从数据源
public void initDataSources(DataSource masterDataSource, DataSource slaveDataSource) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource);
targetDataSources.put("slave", slaveDataSource);
this.setTargetDataSources(targetDataSources);
this.setDefaultTargetDataSource(masterDataSource);
}
}
然后可以通过注解+AOP的方式,在方法执行前切换对应的数据源:
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSourceRoute {
String value() default "master";
}
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class DataSourceAspect {
@Around("@annotation(dataSourceRoute)")
public Object around(ProceedingJoinPoint joinPoint, DataSourceRoute dataSourceRoute) throws Throwable {
String key = dataSourceRoute.value();
ReadWriteRoutingDataSource.setDataSourceKey(key);
try {
return joinPoint.proceed();
} finally {
ReadWriteRoutingDataSource.clearDataSourceKey();
}
}
}
查询缓存方案的落地实践
读写分离能提升读吞吐量,但如果大量请求查询的是相同的数据,从库依然会承受重复查询的压力。此时引入查询缓存,将热点数据存储在内存中间件中,能进一步降低数据库的请求量。
缓存的常见使用策略
最常用的策略是Cache Aside Pattern(旁路缓存模式):
- 读请求先查询缓存,如果缓存有数据直接返回
- 缓存没有数据时查询数据库,将查询结果写入缓存再返回
- 写请求更新数据库后,删除对应的缓存,避免缓存与数据库数据不一致
Redis缓存实现示例
下面是一个结合Redis的查询缓存实现示例:
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class UserService {
@Resource
private UserMapper userMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private static final String USER_CACHE_KEY_PREFIX = "user:id:";
// 读请求加缓存
@DataSourceRoute("slave")
public User getUserById(Long userId) {
String cacheKey = USER_CACHE_KEY_PREFIX + userId;
// 先查缓存
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
return (User) cacheValue;
}
// 缓存没有查数据库
User user = userMapper.selectById(userId);
if (user != null) {
// 写入缓存,设置30分钟过期
redisTemplate.opsForValue().set(cacheKey, user, 30, java.util.concurrent.TimeUnit.MINUTES);
}
return user;
}
// 写请求更新后删除缓存
@DataSourceRoute("master")
public void updateUser(User user) {
userMapper.updateById(user);
// 删除对应的缓存
String cacheKey = USER_CACHE_KEY_PREFIX + user.getId();
redisTemplate.delete(cacheKey);
}
}
方案的注意事项
落地读写分离和查询缓存时,需要注意以下几个问题:
- 主从复制存在延迟,写后立即读的场景可能需要强制走主库,避免读到旧数据
- 缓存需要设置合理的过期时间,避免数据长期不一致,同时防止缓存雪崩
- 读请求路由时需要考虑从库的健康状态,自动剔除故障的从库节点
- 热点数据缓存需要做好互斥锁,避免大量请求同时穿透到数据库
总结
应对突发高并发读请求,读写分离负责提升整体的读吞吐能力,查询缓存负责降低重复查询的数据库压力,两者结合能构建更稳定的读服务架构。实际落地时需要根据业务场景调整从库数量、缓存策略,同时做好监控和容灾预案,才能保障系统在流量突增时依然稳定运行。