Appearance
高并发处理方案详解
一、为什么需要高并发?
1.1 什么是高并发?
高并发(High Concurrency)是指系统在短时间内需要处理大量并发请求的能力。在互联网场景下,大量用户同时访问(如秒杀、抢票、双十一),系统必须保证稳定、快速响应。
核心指标:
| 指标 | 全称 | 含义 | 示例 |
|---|---|---|---|
| QPS | Queries Per Second | 每秒查询数 | 搜索引擎每秒处理 10 万次搜索 |
| TPS | Transactions Per Second | 每秒事务数 | 支付系统每秒完成 5 万笔交易 |
| RT | Response Time | 响应时间 | 接口平均响应时间 < 50ms |
| 并发数 | Concurrent Users | 同时处理的请求数 | 系统同时处理 10 万连接 |
| 吞吐量 | Throughput | 单位时间处理的数据量 | 每秒处理 100MB 数据 |
QPS 与 TPS 的区别: QPS 衡量查询次数,一次查询可能涉及多次数据库访问但仍算一次 QPS。TPS 衡量完整业务事务,一笔交易涉及多个操作(创建订单、扣库存、扣余额)才算一次 TPS。
1.2 高并发场景的挑战
| 挑战 | 描述 | 后果 |
|---|---|---|
| 数据库瓶颈 | 单库连接数有限,磁盘 IO 瓶颈 | 请求超时,连接池耗尽 |
| 缓存与数据库不一致 | 缓存更新与数据库更新不在同一事务中 | 读到脏数据 |
| 热点数据 | 少量数据被大量请求集中访问 | 热点 Key 打爆单节点 |
| 资源竞争 | 多线程并发修改同一数据 | 超卖、数据错乱 |
| 服务雪崩 | 一个服务故障导致级联故障 | 整个系统崩溃 |
1.3 高并发系统设计原则
+-------------------+
| 客户端层 | CDN、静态资源缓存
+-------------------+
|
v
+-------------------+
| 接入层 | DNS 负载均衡、LVS、Nginx
+-------------------+
|
v
+-------------------+
| 网关层 | 限流、熔断、降级、鉴权
+-------------------+
|
v
+-------------------+
| 应用层 | 缓存、异步、读写分离
+-------------------+
|
v
+-------------------+
| 数据层 | 分库分表、读写分离、冷热分离
+-------------------+核心原则: 缓存、异步、拆分(分库分表)、限流、降级、熔断。
二、缓存策略
2.1 为什么需要缓存?
无缓存:请求 → 应用服务器 → 数据库(磁盘 IO 慢)
有缓存:请求 → 应用服务器 → 缓存(内存快) → 未命中时查数据库性能对比:
| 存储介质 | 延迟 | 吞吐量 |
|---|---|---|
| L1 Cache | ~1ns | - |
| 内存(Redis) | ~0.1ms | 10万+ QPS |
| SSD 磁盘 | ~0.1ms | 数万 IOPS |
| HDD 磁盘 | ~10ms | 数百 IOPS |
| 网络(同机房) | ~1ms | - |
2.2 缓存读写策略
2.2.1 Cache Aside(旁路缓存,最常用)
读:
1. 先查缓存,命中则直接返回
2. 未命中则查数据库
3. 将数据库结果写入缓存
4. 返回结果
写:
1. 先更新数据库
2. 再删除缓存(不是更新缓存!)java
/**
* Cache Aside 模式实现
*/
@Service
public class ProductService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductDao productDao;
private static final String CACHE_PREFIX = "product:";
// 读:先查缓存,未命中查数据库
public Product getProduct(Long productId) {
String cacheKey = CACHE_PREFIX + productId;
// 1. 查缓存
String json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 2. 缓存未命中,查数据库
Product product = productDao.getById(productId);
if (product != null) {
// 3. 写入缓存,设置过期时间
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product),
30, TimeUnit.MINUTES);
}
return product;
}
// 写:先更新数据库,再删除缓存
@Transactional
public void updateProduct(Product product) {
// 1. 先更新数据库
productDao.update(product);
// 2. 删除缓存(不是更新缓存)
redisTemplate.delete(CACHE_PREFIX + product.getId());
}
}为什么是删除缓存而不是更新缓存?
- 如果更新缓存,并发写入时可能产生脏数据(写 A 先更新缓存,写 B 后更新缓存,但数据库更新顺序相反)
- 删除缓存是幂等的,更安全
- 缓存是读多写少的惰性计算,删除后下次读自然重建
2.2.2 Read/Write Through(读写穿透)
缓存层作为数据的代理,应用只与缓存交互,缓存负责同步数据库。
java
/**
* Read/Write Through 模式
* 应用只操作缓存,缓存负责同步数据库
*/
@Service
public class CacheThroughService {
@Autowired
private CacheManager cacheManager;
@Autowired
private ProductDao productDao;
// Read Through:缓存层自动加载数据库数据
public Product getProduct(Long productId) {
return cacheManager.get("product:" + productId, Product.class, () -> {
// 缓存未命中时,缓存层自动调用此回调查数据库
return productDao.getById(productId);
});
}
// Write Through:缓存层自动同步数据库
public void updateProduct(Product product) {
cacheManager.put("product:" + product.getId(), product, () -> {
// 写入缓存后,缓存层自动调用此回调写数据库
productDao.update(product);
});
}
}2.2.3 Write Behind(写回)
先写缓存,异步批量写数据库。性能最高,但数据丢失风险也最高。
| 策略 | 读流程 | 写流程 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| Cache Aside | 缓存→DB→回填 | DB→删缓存 | 最终一致 | 高 | 通用场景(推荐) |
| Read/Write Through | 缓存层代理 | 缓存层代理 | 强一致 | 中 | 缓存层封装较好 |
| Write Behind | 缓存层代理 | 写缓存→异步刷DB | 弱一致 | 最高 | 写密集型,容忍少量丢失 |
2.3 缓存三大问题
2.3.1 缓存穿透
问题: 查询一个数据库中不存在的数据(如 id=-1),缓存永远不命中,每次请求都穿透到数据库。
请求 → 缓存(miss) → 数据库(miss) → 返回 null
请求 → 缓存(miss) → 数据库(miss) → 返回 null ← 恶意攻击解决方案:
java
/**
* 缓存穿透解决方案
*/
@Service
public class CachePenetrationService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductDao productDao;
// 方案一:缓存空值
public Product getProductWithNullCache(Long productId) {
String cacheKey = "product:" + productId;
String json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
// 命中缓存(包括空值标记)
return "NULL".equals(json) ? null : JSON.parseObject(json, Product.class);
}
Product product = productDao.getById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product),
30, TimeUnit.MINUTES);
} else {
// 缓存空值,过期时间短一些,防止占用内存
redisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
}
return product;
}
// 方案二:布隆过滤器
private BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1000000, 0.01 // 100万容量,1%误判率
);
// 初始化:将所有存在的 ID 加入布隆过滤器
@PostConstruct
public void init() {
List<Long> allIds = productDao.getAllIds();
allIds.forEach(bloomFilter::put);
}
public Product getProductWithBloomFilter(Long productId) {
// 布隆过滤器判断:如果说不存在,那一定不存在
if (!bloomFilter.mightContain(productId)) {
return null; // 直接返回,不查数据库
}
// 可能存在(可能误判),继续查缓存和数据库
String cacheKey = "product:" + productId;
String json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
Product product = productDao.getById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product),
30, TimeUnit.MINUTES);
}
return product;
}
}方案对比:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 缓存空值 | 对不存在的 key 也缓存(value=null) | 简单,易实现 | 消耗内存,可能被恶意构造不同 key |
| 布隆过滤器 | 用位图判断 key 是否存在 | 内存占用极小 | 有误判率,删除困难 |
| 参数校验 | 对 id 做合法性校验(如 id>0) | 零成本 | 只能拦截明显非法请求 |
| 接口限流 | 对可疑请求增加访问频率限制 | 防护恶意攻击 | 需要识别规则 |
2.3.2 缓存击穿
问题: 热点 key 过期瞬间,大量请求同时打到数据库,数据库压力骤增。
时间线:
T0: 热点 key 缓存过期
T1: 1000 个并发请求同时到达
T2: 都发现缓存 miss
T3: 1000 个请求同时查询数据库 ← 数据库崩溃解决方案:
java
/**
* 缓存击穿解决方案:互斥锁 + 逻辑过期
*/
@Service
public class CacheBreakdownService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductDao productDao;
// 方案一:互斥锁(简单有效)
public Product getProductWithMutex(Long productId) {
String cacheKey = "product:" + productId;
String lockKey = "lock:product:" + productId;
// 1. 查缓存
String json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 2. 缓存未命中,尝试获取互斥锁
String requestId = UUID.randomUUID().toString();
try {
// 自旋等待获取锁
while (!tryLock(lockKey, requestId, 10)) {
Thread.sleep(50);
// 等待期间再次检查缓存(可能其他线程已重建)
json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
}
// 3. 获取锁成功,再次检查缓存(双重检查)
json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 4. 查数据库并重建缓存
Product product = productDao.getById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product),
30, TimeUnit.MINUTES);
}
return product;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
unlock(lockKey, requestId);
}
}
// 方案二:逻辑过期(永不过期,后台异步刷新)
@Data
static class RedisData {
private Object data;
private LocalDateTime expireTime; // 逻辑过期时间
}
public Product getProductWithLogicalExpire(Long productId) {
String cacheKey = "product:" + productId;
String json = redisTemplate.opsForValue().get(cacheKey);
if (json == null) {
return null; // 缓存未命中(预先加载了热点数据)
}
RedisData redisData = JSON.parseObject(json, RedisData.class);
Product product = JSON.parseObject(redisData.getData().toString(), Product.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否逻辑过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回
return product;
}
// 已过期,尝试获取互斥锁,异步重建缓存
String lockKey = "lock:product:" + productId;
String requestId = UUID.randomUUID().toString();
if (tryLock(lockKey, requestId, 10)) {
try {
// 开启独立线程异步重建缓存
CompletableFuture.runAsync(() -> {
Product newProduct = productDao.getById(productId);
RedisData newRedisData = new RedisData();
newRedisData.setData(newProduct);
newRedisData.setExpireTime(LocalDateTime.now().plusMinutes(30));
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(newRedisData));
});
} finally {
unlock(lockKey, requestId);
}
}
// 返回旧数据(保证可用性)
return product;
}
private boolean tryLock(String key, String value, long expireSeconds) {
return Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS)
);
}
private void unlock(String key, String value) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key), value);
}
}2.3.3 缓存雪崩
问题: 大量缓存 key 在同一时间过期,或者 Redis 集群宕机,所有请求直接打到数据库,导致数据库崩溃。
解决方案:
java
/**
* 缓存雪崩解决方案
*/
@Service
public class CacheAvalancheService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 方案一:过期时间加随机值
* 避免大量 key 同时过期
*/
public void setWithRandomExpire(String key, Object value, long baseExpireMinutes) {
// 在基础过期时间上增加随机偏移(如 ±30%)
int randomOffset = ThreadLocalRandom.current().nextInt(
(int)(baseExpireMinutes * 0.7 * 60),
(int)(baseExpireMinutes * 1.3 * 60)
);
redisTemplate.opsForValue().set(key, JSON.toJSONString(value),
randomOffset, TimeUnit.SECONDS);
}
/**
* 方案二:Redis 高可用集群
* Redis Sentinel 或 Redis Cluster,避免单点故障
*/
@Configuration
public class RedisClusterConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// Redis Sentinel 配置
RedisSentinelConfiguration sentinelConfig =
new RedisSentinelConfiguration()
.master("mymaster")
.sentinel("127.0.0.1", 26379)
.sentinel("127.0.0.1", 26380)
.sentinel("127.0.0.1", 26381);
return new LettuceConnectionFactory(sentinelConfig);
}
}
/**
* 方案三:多级缓存(本地缓存 + Redis)
* Redis 不可用时降级到本地缓存
*/
private final Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Product getProductWithMultiLevel(Long productId) {
// 1. 查本地缓存(Caffeine)
Product product = localCache.getIfPresent(productId);
if (product != null) {
return product;
}
// 2. 查 Redis(带降级)
try {
String json = redisTemplate.opsForValue().get("product:" + productId);
if (json != null) {
product = JSON.parseObject(json, Product.class);
localCache.put(productId, product); // 回填本地缓存
return product;
}
} catch (Exception e) {
log.error("Redis 不可用,降级到数据库", e);
}
// 3. 查数据库
product = productDao.getById(productId);
if (product != null) {
localCache.put(productId, product);
// 尝试写 Redis(失败不影响)
try {
redisTemplate.opsForValue().set("product:" + productId,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
} catch (Exception ignored) {}
}
return product;
}
}三大问题总结:
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 缓存穿透 | 查不存在的数据,每次都打 DB | 恶意攻击或业务漏洞 | 缓存空值、布隆过滤器、参数校验 |
| 缓存击穿 | 热点 key 过期,瞬间大量请求打 DB | 热点 key 过期 + 高并发 | 互斥锁、逻辑过期、永不过期 |
| 缓存雪崩 | 大量 key 同时过期或 Redis 宕机 | 过期时间集中、单点故障 | 随机过期时间、Redis 集群、多级缓存 |
三、消息队列
3.1 为什么需要消息队列?
消息队列在架构中承担三大核心角色:
| 角色 | 描述 | 典型场景 |
|---|---|---|
| 解耦 | 生产者和消费者不直接依赖,通过消息通信 | 订单创建后通知库存、物流、积分 |
| 异步 | 非核心流程异步化,减少响应时间 | 注册后发送邮件、短信(异步处理) |
| 削峰 | 缓冲瞬时流量高峰,保护下游系统 | 秒杀请求先入队列,后台匀速处理 |
同步调用(无 MQ): 异步调用(有 MQ):
用户 → 创建订单 → 扣库存 → 发短信 → 积分 用户 → 创建订单 → 返回成功
(等待 500ms) ↓
消息队列
↓ ↓ ↓
扣库存 发短信 积分3.2 Kafka vs RocketMQ
| 维度 | Kafka | RocketMQ |
|---|---|---|
| 设计目标 | 高吞吐日志处理 | 可靠消息、事务消息 |
| 吞吐量 | 极高(百万级/秒) | 高(十万级/秒) |
| 延迟 | 毫秒级 | 毫秒级 |
| 消息可靠性 | 中(可配置为高可靠) | 高(同步刷盘) |
| 事务消息 | 不支持(需外部实现) | 原生支持 |
| 顺序消息 | 分区内有序 | 严格有序 |
| 延时消息 | 不支持原生 | 支持(18 个级别) |
| 适用场景 | 日志收集、流处理、大数据 | 电商交易、金融支付 |
| 社区生态 | 极其丰富 | 阿里主导,国内流行 |
3.3 消息队列代码示例
java
// ========== RocketMQ 生产者 ==========
@Service
public class OrderMessageProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 发送普通消息
*/
public void sendOrderCreatedMessage(Order order) {
OrderCreatedEvent event = OrderCreatedEvent.builder()
.orderId(order.getId())
.userId(order.getUserId())
.amount(order.getAmount())
.build();
// 同步发送,保证可靠性
rocketMQTemplate.syncSend("order-created-topic", event);
}
/**
* 发送事务消息(保证本地事务和消息发送的原子性)
*/
@Transactional
public void createOrderWithTransaction(CreateOrderRequest request) {
// 发送半消息(half message)
rocketMQTemplate.sendMessageInTransaction(
"order-created-topic",
MessageBuilder.withPayload(request).build(),
request.getOrderId() // 事务参数
);
}
}
// ========== RocketMQ 事务消息监听器 ==========
@RocketMQTransactionListener
public class OrderTransactionListener implements RocketMQLocalTransactionListener {
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 执行本地事务(创建订单)
try {
orderService.createOrder((CreateOrderRequest) arg);
return RocketMQLocalTransactionState.COMMIT; // 提交消息
} catch (Exception e) {
return RocketMQLocalTransactionState.ROLLBACK; // 回滚消息
}
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
// 回查:检查本地事务是否执行成功
Long orderId = (Long) msg.getHeaders().get("orderId");
Order order = orderService.getById(orderId);
return order != null ? RocketMQLocalTransactionState.COMMIT
: RocketMQLocalTransactionState.ROLLBACK;
}
}
// ========== RocketMQ 消费者 ==========
@Component
@RocketMQMessageListener(
topic = "order-created-topic",
consumerGroup = "inventory-consumer-group",
consumeMode = ConsumeMode.CONCURRENTLY // 并发消费
)
public class OrderCreatedConsumer implements RocketMQListener<OrderCreatedEvent> {
@Override
public void onMessage(OrderCreatedEvent event) {
// 幂等处理:通过 messageId 或业务唯一键去重
String messageKey = event.getOrderId().toString();
if (isDuplicate(messageKey)) {
return;
}
// 扣减库存
inventoryService.deductStock(event.getProductId(), event.getQuantity());
// 标记已处理
markProcessed(messageKey);
}
private boolean isDuplicate(String messageKey) {
return Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(
"msg:dedup:" + messageKey, "1", 24, TimeUnit.HOURS
)
) == false;
}
}四、CDN
4.1 原理
CDN(Content Delivery Network)将静态资源(图片、CSS、JS、视频)缓存到离用户最近的边缘节点,用户从最近的节点获取资源,减少延迟和源站压力。
北京用户 → 北京 CDN 节点(命中 cache) → 返回资源(10ms)
上海用户 → 上海 CDN 节点(命中 cache) → 返回资源(10ms)
↑
↓ 未命中时回源
源站(杭州)4.2 实现要点
- 静态资源(图片、CSS、JS)使用 CDN 加速
- 动态内容(API)不走 CDN
- 资源文件名使用 hash(如
app.abc123.js),实现永久缓存 - 设置合理的 Cache-Control 头
五、读写分离
5.1 原理
主库负责写入,从库负责读取,通过主从复制(Binlog)同步数据。
+---------+
| 主库 | ← 写入(INSERT/UPDATE/DELETE)
+---------+
|
Binlog 同步
|
+--------+--------+
| |
+---------+ +---------+
| 从库1 | | 从库2 | ← 读取(SELECT)
+---------+ +---------+5.2 实现
java
/**
* 读写分离:通过 AbstractRoutingDataSource 实现动态数据源切换
*/
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DataSourceType.MASTER, masterDataSource());
targetDataSources.put(DataSourceType.SLAVE, slaveDataSource());
RoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setDefaultTargetDataSource(masterDataSource());
routingDataSource.setTargetDataSources(targetDataSources);
return routingDataSource;
}
}
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceType();
}
}
// 线程上下文持有数据源类型
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceType> CONTEXT = new ThreadLocal<>();
public static void setMaster() { CONTEXT.set(DataSourceType.MASTER); }
public static void setSlave() { CONTEXT.set(DataSourceType.SLAVE); }
public static DataSourceType getDataSourceType() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}
// 通过 AOP 自动切换
@Aspect
@Component
public class ReadOnlyDataSourceAspect {
@Before("@annotation(org.springframework.transaction.annotation.Transactional)")
public void beforeWrite(JoinPoint joinPoint) {
// 写操作走主库
DataSourceContextHolder.setMaster();
}
@Before("@annotation(com.example.annotation.ReadOnly)")
public void beforeRead(JoinPoint joinPoint) {
// 读操作走从库
DataSourceContextHolder.setSlave();
}
@After("@annotation(com.example.annotation.ReadOnly) || "
+ "@annotation(org.springframework.transaction.annotation.Transactional)")
public void after() {
DataSourceContextHolder.clear();
}
}读写分离的挑战:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 主从延迟 | 从库同步落后于主库 | 1. 写后立即读强制走主库 2. 业务容忍短暂延迟 |
| 数据不一致 | 延迟导致的读旧数据 | 关键业务强制读主库(如支付后查余额) |
| 从库故障 | 从库宕机 | 多从库 + 故障转移 |
六、数据库分库分表
6.1 垂直拆分 vs 水平拆分
| 维度 | 垂直拆分 | 水平拆分 |
|---|---|---|
| 拆分维度 | 按业务模块(列) | 按数据行 |
| 方式 | 不同表放不同库 | 同一表数据分散到多个库/表 |
| 解决的问题 | 单库表太多、业务耦合 | 单表数据量太大 |
| 举例 | 用户库、订单库、商品库 | 订单表按 user_id 分 16 个库 |
垂直拆分: 水平拆分:
┌──────────┐ ┌──────────────────┐
│ 用户库 │ │ 订单库_0 (user_id%2=0) │
│ user表 │ │ order_0, order_1 │
│ addr表 │ └──────────────────┘
└──────────┘ ┌──────────────────┐
┌──────────┐ │ 订单库_1 (user_id%2=1) │
│ 订单库 │ │ order_0, order_1 │
│ order表 │ └──────────────────┘
│ item表 │
└──────────┘6.2 分片键(Sharding Key)选择
| 分片键 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| user_id | 用户维度查询高效 | 商家维度查询需要全表扫描 | C 端业务 |
| order_id | 均匀分布 | 按用户查询需要全表扫描 | 订单中心 |
| 时间(年月) | 按时间范围查询高效 | 热点数据集中 | 日志、流水 |
分片键选择原则: 选择最常用查询条件作为分片键,避免跨分片查询。
6.3 ShardingSphere 实现分库分表
java
// ShardingSphere-JDBC 配置 (application.yml)
spring:
shardingsphere:
datasource:
names: ds0, ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/order_db_0
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/order_db_1
rules:
sharding:
tables:
t_order:
# 分库策略:user_id % 2
actual-data-nodes: ds$->{0..1}.t_order_$->{0..1}
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-inline
# 分表策略:order_id % 2
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: table-inline
sharding-algorithms:
database-inline:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 2}
table-inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 2}
// 使用:完全透明,像普通数据库一样操作
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
public Order getOrder(Long orderId, Long userId) {
// ShardingSphere 自动路由到正确的库和表
return orderMapper.selectOne(
new LambdaQueryWrapper<Order>()
.eq(Order::getOrderId, orderId)
.eq(Order::getUserId, userId)
);
}
}七、限流算法
7.1 算法对比
| 算法 | 原理 | 特点 | 适用场景 |
|---|---|---|---|
| 固定窗口 | 统计固定时间窗口内的请求数 | 简单,但有临界问题 | 粗粒度限流 |
| 滑动窗口 | 滑动时间窗口,更平滑 | 精确,实现稍复杂 | 精确限流 |
| 漏桶 | 请求进桶,固定速率流出 | 严格平滑,不允许突发 | 保护下游稳定 |
| 令牌桶 | 固定速率放令牌,请求获取令牌 | 允许突发流量 | 网关层限流(推荐) |
7.2 令牌桶限流实现
java
/**
* 令牌桶限流算法
* 以固定速率生成令牌,请求需获取令牌才能执行
*/
public class TokenBucketRateLimiter {
private final long capacity; // 桶容量
private final double rate; // 令牌生成速率(个/秒)
private double tokens; // 当前令牌数
private long lastRefillTime; // 上次填充时间
public TokenBucketRateLimiter(long capacity, double rate) {
this.capacity = capacity;
this.rate = rate;
this.tokens = capacity; // 初始满桶
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryAcquire() {
refill();
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false; // 限流
}
private void refill() {
long now = System.nanoTime();
double elapsedSeconds = (now - lastRefillTime) / 1_000_000_000.0;
double newTokens = elapsedSeconds * rate;
tokens = Math.min(capacity, tokens + newTokens);
lastRefillTime = now;
}
}
// 使用示例
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(100, 10); // 容量100,速率10/s
if (limiter.tryAcquire()) {
// 处理请求
} else {
throw new RateLimitException("请求过于频繁,请稍后重试");
}7.3 滑动窗口限流(Redis 实现)
java
/**
* 滑动窗口限流:基于 Redis ZSET 实现
* 精确控制任意时间窗口内的请求数
*/
@Component
public class SlidingWindowRateLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 滑动窗口限流
* @param key 限流 key
* @param limit 窗口内最大请求数
* @param windowMs 窗口大小(毫秒)
*/
public boolean isAllowed(String key, int limit, long windowMs) {
long now = System.currentTimeMillis();
long windowStart = now - windowMs;
String script =
"local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local windowStart = tonumber(ARGV[2]) " +
"local limit = tonumber(ARGV[3]) " +
// 删除窗口外的记录
"redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) " +
// 统计窗口内的请求数
"local count = redis.call('ZCARD', key) " +
"if count < limit then " +
" redis.call('ZADD', key, now, now) " +
" redis.call('PEXPIRE', key, ARGV[4]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(windowStart),
String.valueOf(limit),
String.valueOf(windowMs * 2) // key 过期时间设为窗口的 2 倍
);
return result != null && result == 1L;
}
}
// 配合拦截器使用
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Autowired
private SlidingWindowRateLimiter rateLimiter;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
String userId = request.getHeader("X-User-Id");
String key = "rate_limit:" + request.getRequestURI() + ":" + userId;
if (!rateLimiter.isAllowed(key, 100, 1000)) { // 每用户每秒 100 次
response.setStatus(429);
response.getWriter().write("{\"code\":429,\"message\":\"Too Many Requests\"}");
return false;
}
return true;
}
}八、面试高频问题
Q1: 如何设计一个秒杀系统?
秒杀系统的核心挑战:瞬时高并发(10 万+ QPS)、防止超卖、防止刷单。架构设计要点:
- 前端:按钮置灰、验证码、答题防刷
- 网关:令牌桶限流、黑名单
- 服务层:Redis 预扣库存(原子操作
DECR)、消息队列异步下单 - 数据库:分库分表、乐观锁防止超卖
- 关键:大部分流量在网关和缓存层拦截,不到数据库
Q2: 缓存和数据库一致性问题如何解决?
- 采用 Cache Aside 模式:先更新数据库,再删除缓存
- 延迟双删:更新数据库前删一次,更新后再延迟删一次(防止并发读写入旧缓存)
- 订阅 Binlog + 异步更新缓存(Canal + MQ)
- 最终一致性兜底:设置缓存过期时间,保证最终一致
Q3: 为什么 Redis 这么快?
- 纯内存操作,数据存储在内存中
- 单线程模型,避免上下文切换和锁竞争(Redis 6.0+ 网络 IO 多线程)
- 基于 epoll 的多路复用 IO 模型
- 高效的数据结构(SDS、ziplist、skiplist 等)
Q4: Kafka 如何保证消息不丢失?
- 生产者:acks=all(等待所有副本确认),retries 配置重试
- 服务端:min.insync.replicas >= 2,确保至少两个副本写入成功
- 消费者:手动提交 offset,处理完业务再提交
- 代价:吞吐量降低,延迟增加
Q5: 分库分表后如何做跨分片查询?
- 尽量避免跨分片查询(设计时考虑)
- 使用搜索引擎(ES)做全局搜索
- 使用中间表或汇总表做聚合查询
- 使用异构索引(如基因法,将关联关系编码到 ID 中)
- 实在不行:逐分片查询后合并(性能差)
Q6: 如何保证消息队列的幂等性?
- 数据库唯一键约束:如订单号唯一索引
- Redis 去重:
SETNX msg:dedup:{messageId} 1 - 业务状态机:如订单状态只能从"待支付"到"已支付",重复消息不影响
- 生产者:发送消息时携带唯一业务 ID
Q7: 令牌桶和漏桶的区别是什么?
- 漏桶:固定速率流出,不允许突发流量,适合保护下游(如数据库)
- 令牌桶:固定速率生成令牌,允许突发(桶容量内的令牌可一次性使用),适合网关层
- 选择:保护下游用漏桶,网关入口用令牌桶
