Skip to content

高并发处理方案详解

一、为什么需要高并发?

1.1 什么是高并发?

高并发(High Concurrency)是指系统在短时间内需要处理大量并发请求的能力。在互联网场景下,大量用户同时访问(如秒杀、抢票、双十一),系统必须保证稳定、快速响应。

核心指标:

指标全称含义示例
QPSQueries Per Second每秒查询数搜索引擎每秒处理 10 万次搜索
TPSTransactions Per Second每秒事务数支付系统每秒完成 5 万笔交易
RTResponse 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.1ms10万+ 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

维度KafkaRocketMQ
设计目标高吞吐日志处理可靠消息、事务消息
吞吐量极高(百万级/秒)高(十万级/秒)
延迟毫秒级毫秒级
消息可靠性中(可配置为高可靠)高(同步刷盘)
事务消息不支持(需外部实现)原生支持
顺序消息分区内有序严格有序
延时消息不支持原生支持(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: 令牌桶和漏桶的区别是什么?

  • 漏桶:固定速率流出,不允许突发流量,适合保护下游(如数据库)
  • 令牌桶:固定速率生成令牌,允许突发(桶容量内的令牌可一次性使用),适合网关层
  • 选择:保护下游用漏桶,网关入口用令牌桶