电商秒杀系统崩溃怎么办MySQL高并发下的读写分离分库分表与缓存优化实战方案

那个凌晨三点被电话叫醒的夜晚

2023年双11凌晨,我的手机响了。是运维老张,声音都在抖:”服务器扛不住了,秒杀接口全在报500”。屏幕上一片红,QPS从平时的几千瞬间飙到八万多,MySQL连接池直接爆掉,缓存命中率跌破10%。那一刻,我站在公司落地窗前,看着凌晨的北京,心里只有一个念头:这仗,得重新打。

今天这篇,就是把那次教训全部复盘出来,从读写分离到分库分表,再到缓存架构,每一条都是血泪换来的实战经验。


一、先搞清楚,秒杀到底在跟谁作战

很多人以为秒杀就是”很多人同时买东西”,太简单化了。你面对的是三座大山:

量级的暴力。平时每秒几百个请求,秒杀时是几十万的瞬时峰值,数据库的连接数、CPU、内存,哪个都不是线性增长,而是指数级爆炸。

时间的残酷。秒杀就那一秒、两秒,系统必须在这个窗口内扛住,慢一秒,排队的人就崩了。

一致的悖论。用户看到”抢购成功”,但库存不能超卖,不能少卖,这在分布式环境下是个经典难题。

所以,你的架构必须像特种部队一样,平时隐蔽,战时精准。下面这些方案,是我在无数次压测和故障中打磨出来的。


二、读写分离:别把所有压力都给一个人扛

为什么需要读写分离

想象一下,你的MySQL主库既要写(库存扣减、订单生成),又要读(商品详情、活动信息),结果就是:写的地方排队,读的地方也堵死。主库负载过高,直接拖垮整个系统。

读写分离的本质,是把读操作分流到从库,让主库专心写。

架构设计

                    ┌──────────────┐
                    │   网关/负载均衡  │
                    └──────┬───────┘
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │  业务读库1 │ │  业务读库2 │ │  业务读库3 │
        └────┬─────┘ └────┬─────┘ └────┬─────┘
             │            │            │
             └────────────┴────────────┘
                          │
                          ▼
                    ┌──────────┐
                    │  MySQL主库  │ ← 只负责写操作
                    │ (Master)  │
                    └──────────┘
                          │
                    异步复制
                          │
                    ┌─────┴─────┐
                    ▼           ▼
                 从库1        从库2

实战代码:MyBatis读写分离配置

<!-- druid连接池配置,读写分离核心 -->
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close">
    <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
    <property name="url" value="jdbc:mysql://localhost:3306/seckill_db"/>
    <property name="username" value="root"/>
    <property name="password" value="password"/>
    <!-- 最大连接数 -->
    <property name="maxActive" value="200"/>
    <!-- 最小连接数 -->
    <property name="minIdle" value="10"/>
    <!-- 连接等待超时时间 -->
    <property name="maxWait" value="3000"/>
</bean>

<!-- 动态数据源,实现读写分离 -->
<bean id="dynamicDataSource" class="com.yourcompany.datasource.DynamicDataSource">
    <property name="targetDataSources">
        <map key-type="java.lang.String">
            <!-- 写数据源 -->
            <entry key="write" value-ref="writeDataSource"/>
            <!-- 读数据源 -->
            <entry key="read1" value-ref="readDataSource1"/>
            <entry key="read2" value-ref="readDataSource2"/>
        </map>
    </property>
    <!-- 默认数据源 -->
    <property name="defaultTargetDataSource" ref="writeDataSource"/>
</bean>

<!-- 写数据源 -->
<bean id="writeDataSource" parent="dataSource">
    <property name="url" value="jdbc:mysql://master:3306/seckill_db"/>
</bean>

<!-- 读数据源1 -->
<bean id="readDataSource1" parent="dataSource">
    <property name="url" value="jdbc:mysql://slave1:3306/seckill_db"/>
</bean>

<!-- 读数据源2 -->
<bean id="readDataSource2" parent="dataSource">
    <property name="url" value="jdbc:mysql://slave2:3306/seckill_db"/>
</bean>
// 动态数据源切换逻辑
public class DynamicDataSource extends AbstractRoutingDataSource {
    
    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
    
    /**
     * 决定使用哪个数据源
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return getDataSourceType();
    }
    
    /**
     * 设置数据源类型
     */
    public static void setDataSourceType(String type) {
        CONTEXT_HOLDER.set(type);
    }
    
    /**
     * 获取数据源类型
     */
    public static String getDataSourceType() {
        String type = CONTEXT_HOLDER.get();
        return type != null ? type : "read"; // 默认走读库
    }
    
    /**
     * 清除数据源类型(用完必须清除,否则线程池复用会出问题)
     */
    public static void clearDataSourceType() {
        CONTEXT_HOLDER.remove();
    }
}
// AOP切面,自动根据方法名判断读写
@Aspect
@Component
public class DataSourceAspect {
    
    @Before("@annotation(readOnly)")
    public void setReadDataSource(JoinPoint point, ReadOnly readOnly) {
        DynamicDataSource.setDataSourceType("read");
    }
    
    @Before("@annotation(writeOnly)")
    public void setWriteDataSource(JoinPoint point, WriteOnly writeOnly) {
        DynamicDataSource.setDataSourceType("write");
    }
    
    @AfterReturning
    public void clearDataSource(JoinPoint point) {
        DynamicDataSource.clearDataSourceType();
    }
}
// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WriteOnly {}
// 服务层使用示例
@Service
public class SeckillService {
    
    @WriteOnly
    public SeckillOrder createOrder(Long userId, Long productId) {
        // 扣减库存、生成订单 - 强制走写库
        // ...
    }
    
    @ReadOnly
    public SeckillProduct getProductInfo(Long productId) {
        // 查询商品详情 - 走读库
        // ...
    }
}

读写分离的几个坑

主从延迟。这是读写分离最大的敌人。如果从库还没同步完主库的数据,用户刚下单就去查订单,可能查不到。解决方案:

// 关键写操作后,强制读主库
public SeckillOrder createOrderAndQuery(Long userId, Long productId) {
    // 1. 写操作
    SeckillOrder order = seckillMapper.insertOrder(userId, productId);
    
    // 2. 写完后,强制读主库,避免查到旧数据
    DynamicDataSource.setDataSourceType("write");
    try {
        // 3. 查询刚才插入的订单
        return seckillMapper.selectById(order.getId());
    } finally {
        DynamicDataSource.clearDataSourceType();
    }
}

读库故障。多搞几个读库,负载均衡,一个挂了自动切换。


三、分库分表:解决单库性能瓶颈的终极武器

为什么要分库分表

一个MySQL实例,哪怕配置再高,能扛的QPS也有上限。当单表数据超过千万级,查询性能断崖式下跌。分库分表就是把一个大库拆成多个小库,一个大表拆成多个小表,让每个实例扛的负载可控。

分库策略

常见的分库方式有几种:

方式 优点 缺点
按用户ID取模 均匀分布,扩展方便 跨库查询复杂
按地区分 本地化访问快 地区分布不均
按时间分 历史数据好管理 新数据集中

秒杀系统建议用用户ID取模,因为每个用户的请求量相对均匀。

/**
 * 分库分表工具类
 */
public class ShardingUtils {
    
    /**
     * 根据用户ID计算库索引
     */
    public static int getDbIndex(Long userId, int dbCount) {
        return (int) (Math.abs(userId) % dbCount);
    }
    
    /**
     * 根据订单ID计算表索引
     */
    public static int getTableIndex(Long orderId, int tableCount) {
        return (int) (Math.abs(orderId) % tableCount);
    }
}

分表策略

以订单表为例,假设分16张表:

-- 原始表
CREATE TABLE seckill_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    order_status TINYINT DEFAULT 0,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_user_id (user_id),
    INDEX idx_create_time (create_time)
);

-- 分表后(16张表)
CREATE TABLE seckill_order_00 (LIKE seckill_order);
CREATE TABLE seckill_order_01 (LIKE seckill_order);
-- ... 到 seckill_order_15

实战:动态SQL路由

@Repository
public class SeckillOrderMapper {
    
    @Autowired
    private DataSource dynamicDataSource;
    
    /**
     * 插入订单,自动路由到正确的库和表
     */
    @WriteOnly
    public long insertOrder(SeckillOrder order) {
        // 1. 确定库
        int dbIndex = ShardingUtils.getDbIndex(order.getUserId(), 4); // 4个库
        DataSource db = getDataSourceByIndex(dbIndex);
        
        // 2. 确定表
        long orderId = generateOrderId(); // 用雪花算法生成全局唯一ID
        int tableIndex = ShardingUtils.getTableIndex(orderId, 16); // 16张表
        String tableName = "seckill_order_" + String.format("%02d", tableIndex);
        
        // 3. 切换到对应数据源执行
        DynamicDataSource.setDataSourceType("write_" + dbIndex);
        try {
            String sql = "INSERT INTO " + tableName 
                + " (id, user_id, product_id, order_status, create_time) "
                + "VALUES (#id#, #userId#, #productId#, #orderStatus#, #createTime#)";
            
            KeyGeneratedKeyHolder keyHolder = new KeyGeneratedKeyHolder();
            jdbcTemplate.update(connection -> {
                PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
                ps.setLong(1, orderId);
                ps.setLong(2, order.getUserId());
                ps.setLong(3, order.getProductId());
                ps.setInt(4, order.getOrderStatus());
                ps.setTimestamp(5, new Timestamp(order.getCreateTime().getTime()));
                return ps;
            }, keyHolder);
            
            return (long) keyHolder.getKey();
        } finally {
            DynamicDataSource.clearDataSourceType();
        }
    }
}

分布式ID生成:雪花算法

分库分表后,不能再用自增ID,需要全局唯一ID:

public class SnowflakeIdGenerator {
    
    /** 起始时间戳 (2020-01-01) */
    private static final long EPOCH = 1577836800000L;
    
    /** 机器ID所占位数 */
    private static final long WORKER_ID_BITS = 5L;
    
    /** 数据中心ID所占位数 */
    private static final long DATA_CENTER_ID_BITS = 5L;
    
    /** 序列号所占位数 */
    private static final long SEQUENCE_BITS = 12L;
    
    /** 机器ID最大值 */
    private static final long MAX_WORKER_ID = -1L ^ (-1L << WORKER_ID_BITS);
    
    /** 数据中心ID最大值 */
    private static final long MAX_DATA_CENTER_ID = -1L ^ (-1L << DATA_CENTER_ID_BITS);
    
    /** 机器ID左移位数 */
    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    
    /** 数据中心ID左移位数 */
    private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
    
    /** 时间戳左移位数 */
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS;
    
    /** 序列号掩码 */
    private static final long SEQUENCE_MASK = -1L ^ (-1L << SEQUENCE_BITS);
    
    private long workerId;
    private long dataCenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    public SnowflakeIdGenerator(long workerId, long dataCenterId) {
        if (workerId > MAX_WORKER_ID || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", MAX_WORKER_ID));
        }
        if (dataCenterId > MAX_DATA_CENTER_ID || dataCenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", MAX_DATA_CENTER_ID));
        }
        this.workerId = workerId;
        this.dataCenterId = dataCenterId;
    }
    
    /**
     * 生成下一个ID
     */
    public synchronized long nextId() {
        long timestamp = timeGen();
        
        // 时钟回拨检测
        if (timestamp < lastTimestamp) {
            // 容忍5毫秒内的回拨,超过则抛出异常
            long offset = lastTimestamp - timestamp;
            if (offset <= 5) {
                try {
                    waitForMillis(offset);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            } else {
                throw new IllegalStateException("Clock moved backwards. Refusing to generate id for " + offset + " milliseconds");
            }
            timestamp = timeGen();
            if (timestamp < lastTimestamp) {
                throw new IllegalStateException("Clock moved backwards. Refusing to generate id");
            }
        }
        
        // 同一毫秒内,序列号自增
        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & SEQUENCE_MASK;
            // 序列号溢出,等下一毫秒
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            // 不同毫秒,序列号重置
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        // 组装ID
        return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
            | (dataCenterId << DATA_CENTER_ID_SHIFT)
            | (workerId << WORKER_ID_SHIFT)
            | sequence;
    }
    
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }
    
    private void waitForMillis(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    
    private long timeGen() {
        return System.currentTimeMillis();
    }
}

四、缓存优化:秒杀系统的最后一道防线

为什么缓存是必须滴

没有缓存的秒杀系统,每一次请求都要打穿到数据库,这跟裸奔没什么区别。缓存是秒杀系统的”缓冲垫”,把大部分请求挡在数据库外面。

缓存架构设计

用户请求
    │
    ▼
┌─────────────────┐
│   CDN/边缘缓存    │ ← 静态资源、商品图片
└────────┬────────┘
         │
    ┌────▼────┐
    │ Redis   │ ← 热点数据、库存扣减
    │ (集群)   │
    └────┬────┘
         │ 缓存未命中
    ┌────▼────┐
    │ MySQL   │ ← 持久化存储
    └─────────┘

Redis缓存策略

策略一:Cache Aside Pattern(旁路缓存)

@Service
public class ProductService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private SeckillProductMapper productMapper;
    
    /**
     * 查询商品,先查缓存,缓存没有再查数据库
     */
    public SeckillProduct getProduct(Long productId) {
        // 1. 查缓存
        String cacheKey = "seckill:product:" + productId;
        String cacheValue = redisTemplate.opsForValue().get(cacheKey);
        
        if (cacheValue != null) {
            // 缓存命中,直接返回
            return JSON.parseObject(cacheValue, SeckillProduct.class);
        }
        
        // 2. 缓存未命中,查数据库
        SeckillProduct product = productMapper.selectById(productId);
        if (product == null) {
            // 防止缓存穿透,空值也缓存
            redisTemplate.opsForValue().set(cacheKey, "NULL", 30, TimeUnit.SECONDS);
            return null;
        }
        
        // 3. 写入缓存
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.SECONDS);
        
        return product;
    }
    
    /**
     * 更新商品,先更新数据库,再删除缓存
     */
    public void updateProduct(SeckillProduct product) {
        productMapper.updateById(product);
        // 删除缓存,下次查询会重新加载
        String cacheKey = "seckill:product:" + product.getId();
        redisTemplate.delete(cacheKey);
    }
}

策略二:秒杀库存的缓存扣减

这是最关键的部分。秒杀的核心是库存扣减,如果用数据库来扣,扛不住高并发。

@Service
public class SeckillService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private SeckillOrderMapper orderMapper;
    
    /**
     * 秒杀扣减库存
     * 使用Redis原子操作,避免超卖
     */
    @WriteOnly
    public SeckillResult seckill(Long userId, Long productId) {
        String stockKey = "seckill:stock:" + productId;
        String userKey = "seckill:user:" + productId + ":" + userId;
        
        // 1. 检查用户是否已抢购(防重复购买)
        Boolean hasBought = redisTemplate.opsForSet().isMember(userKey, String.valueOf(userId));
        if (Boolean.TRUE.equals(hasBought)) {
            return SeckillResult.fail("您已抢购过该商品");
        }
        
        // 2. 原子扣减库存
        Long remainingStock = redisTemplate.opsForValue().decrement(stockKey);
        if (remainingStock == null || remainingStock < 0) {
            // 库存不足,恢复计数
            redisTemplate.opsForValue().increment(stockKey);
            return SeckillResult.fail("库存不足");
        }
        
        // 3. 记录用户已购买
        redisTemplate.opsForSet().add(userKey, String.valueOf(userId));
        redisTemplate.expire(userKey, 24, TimeUnit.HOURS);
        
        // 4. 异步写入数据库(MQ)
        SeckillMessage message = new SeckillMessage(userId, productId);
        kafkaTemplate.send("seckill-order", JSON.toJSONString(message));
        
        return SeckillResult.success("抢购成功,请等待订单生成");
    }
    
    /**
     * 预扣库存(活动开始前预热)
     */
    public void preLoadStock(Long productId, int totalStock) {
        String stockKey = "seckill:stock:" + productId;
        // 设置初始库存
        redisTemplate.opsForValue().set(stockKey, String.valueOf(totalStock));
        // 设置过期时间,防止活动后数据残留
        redisTemplate.expire(stockKey, 7, TimeUnit.DAYS);
    }
}
/**
 * 库存扣减的Lua脚本(保证原子性)
 */
public class SeckillLuaScript {
    
    private static final String LUA_SCRIPT = 
        "local stock = redis.call('GET', KEYS[1]) " +
        "if not stock then " +
        "    return -2 " +
        "end " +
        "if tonumber(stock) <= 0 then " +
        "    return -1 " +
        "end " +
        "redis.call('DECR', KEYS[1]) " +
        "return 1";
    
    /**
     * 原子扣减库存,返回结果:1成功,-1库存不足,-2不存在
     */
    public static Long decrementStock(RedisTemplate<String, String> redisTemplate, String stockKey) {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptText(LUA_SCRIPT);
        script.setResultType(Long.class);
        return redisTemplate.execute(script, Collections.singletonList(stockKey));
    }
}

缓存穿透、击穿、雪崩的解决方案

穿透:查询不存在的数据,每次都打到数据库。 解决方案:缓存空值。

击穿:热点Key过期,大量请求同时打到数据库。 解决方案:永不过期 + 逻辑过期。

/**
 * 逻辑过期方案,避免缓存击穿
 */
@Service
public class HotProductCacheService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private SeckillProductMapper productMapper;
    
    /**
     * 查询热点商品,使用逻辑过期
     */
    public SeckillProduct getHotProduct(Long productId) {
        String cacheKey = "hot:product:" + productId;
        
        // 1. 获取缓存
        String cacheJson = redisTemplate.opsForValue().get(cacheKey);
        if (cacheJson == null) {
            // 缓存不存在,直接查数据库并写入缓存
            return loadFromDb(productId);
        }
        
        // 2. 解析缓存,检查是否过期
        CacheEntry entry = JSON.parseObject(cacheJson, CacheEntry.class);
        if (entry.isExpire()) {
            // 已过期,异步刷新缓存,当前请求继续使用旧数据
            asyncRefreshCache(productId, entry);
            return entry.getProduct();
        }
        
        // 3. 缓存有效,直接返回
        return entry.getProduct();
    }
    
    /**
     * 异步刷新缓存
     */
    private void asyncRefreshCache(Long productId, CacheEntry entry) {
        CompletableFuture.runAsync(() -> {
            try {
                SeckillProduct product = loadFromDb(productId);
                CacheEntry newEntry = new CacheEntry(product, System.currentTimeMillis() + 60000);
                redisTemplate.opsForValue().set(
                    "hot:product:" + productId,
                    JSON.toJSONString(newEntry),
                    70, TimeUnit.SECONDS
                );
            } catch (Exception e) {
                log.error("刷新缓存失败", e);
            }
        });
    }
    
    private SeckillProduct loadFromDb(Long productId) {
        SeckillProduct product = productMapper.selectById(productId);
        if (product != null) {
            CacheEntry entry = new CacheEntry(product, System.currentTimeMillis() + 60000);
            redisTemplate.opsForValue().set(
                "hot:product:" + productId,
                JSON.toJSONString(entry),
                70, TimeUnit.SECONDS
            );
        }
        return product;
    }
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CacheEntry {
    private SeckillProduct product;
    private long expireTime; // 逻辑过期时间戳
    
    public boolean isExpire() {
        return System.currentTimeMillis() > expireTime;
    }
}

雪崩:大量缓存同时过期。 解决方案:过期时间加随机值。

// 设置过期时间时,加一个随机值
long expireSeconds = 300 + new Random().nextInt(60); // 300-360秒之间随机
redisTemplate.opsForValue().set(key, value, expireSeconds, TimeUnit.SECONDS);

五、完整的秒杀请求流程

把上面所有内容串起来,一个完整的秒杀流程是这样的:

用户点击"抢购"
    │
    ▼
┌─────────────────────────────────────┐
│  1. 网关层:限流、鉴权              │
│     - 每个用户每秒最多1次请求        │
│     - 非法请求直接返回               │
└──────────────┬──────────────────────┘
               │
               ▼
┌─────────────────────────────────────┐
│  2. 缓存层:Redis预扣库存            │
│     - 检查是否已购买(Set)          │
│     - 原子扣减库存(DECR/Lua)       │
│     - 库存不足直接返回               │
└──────────────┬──────────────────────┘
               │
               ▼
┌─────────────────────────────────────┐
│  3. 消息队列:异步下单               │
│     - 发送MQ消息                    │
│     - 立即返回"排队中"               │
└──────────────┬──────────────────────┘
               │
               ▼
┌─────────────────────────────────────┐
│  4. 消费者:异步写入数据库           │
│     - 接收MQ消息                     │
│     - 分库分表写入订单               │
│     - 更新库存(最终一致性)          │
└──────────────┬──────────────────────┘
               │
               ▼
          用户查询订单结果

网关限流代码

@RestController
@RequestMapping("/api/seckill")
public class SeckillController {
    
    @Autowired
    private SeckillService seckillService;
    
    @Autowired
    private RateLimiter rateLimiter;
    
    /**
     * 秒杀接口
     */
    @PostMapping("/seckill/{productId}")
    public ResponseEntity<SeckillResult> seckill(
            @RequestHeader("UserId") Long userId,
            @PathVariable Long productId) {
        
        // 1. 限流检查
        if (!rateLimiter.tryAcquire(userId)) {
            return ResponseEntity.ok(SeckillResult.fail("请求过于频繁,请稍后重试"));
        }
        
        // 2. 执行秒杀
        SeckillResult result = seckillService.seckill(userId, productId);
        
        return ResponseEntity.ok(result);
    }
}
@Component
public class RateLimiter {
    
    // 每个用户每秒最多1次请求
    private final ConcurrentHashMap<Long, Long> requestCounts = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<Long, Long> windowStartTimes = new ConcurrentHashMap<>();
    
    /**
     * 尝试获取许可
     */
    public boolean tryAcquire(Long userId) {
        long now = System.currentTimeMillis();
        long windowStart = now / 1000 * 1000; // 当前秒的起始时间
        
        // 检查是否是新的时间窗口
        Long lastWindowStart = windowStartTimes.get(userId);
        if (lastWindowStart == null || lastWindowStart < windowStart) {
            // 新窗口,重置计数
            requestCounts.put(userId, 1L);
            windowStartTimes.put(userId, windowStart);
            return true;
        }
        
        // 同一窗口内,检查计数
        long count = requestCounts.getOrDefault(userId, 0L);
        if (count >= 1) {
            return false; // 超出限流
        }
        
        requestCounts.put(userId, count + 1);
        return true;
    }
}

六、那些你没想到的细节

1. 数据库连接池调优

秒杀场景下,连接池的参数必须仔细调优:

spring:
  datasource:
    hikari:
      maximum-pool-size: 50        # 最大连接数,根据压测结果调整
      minimum-idle: 10             # 最小空闲连接
      idle-timeout: 30000          # 空闲连接超时
      max-lifetime: 600000         # 连接最大生命周期
      connection-timeout: 3000     # 获取连接超时
      connection-test-query: SELECT 1

2. 索引优化

-- 订单表索引
CREATE INDEX idx_user_product ON seckill_order (user_id, product_id);
CREATE INDEX idx_status_time ON seckill_order (order_status, create_time);

-- 库存表索引
CREATE INDEX idx_product_stock ON seckill_stock (product_id, stock);

3. 数据库参数调优

# my.cnf 关键参数
innodb_buffer_pool_size = 8G        # 缓存池大小,设置为内存的50-70%
innodb_log_file_size = 512M         # 日志文件大小
innodb_flush_log_at_trx_commit = 2  # 每秒刷盘,提升写性能(秒杀场景可容忍)
max_connections = 1000               # 最大连接数
slow_query_log = 1                   # 开启慢查询日志
long_query_time = 0.1                # 超过100ms算慢查询

七、如果还是崩了怎么办

任何系统都有上限,当压力超出设计容量时,你需要”保命”策略:

1. 熔断降级

/**
 * 熔断器配置
 */
@CircuitBreaker(name = "seckillService", fallbackMethod = "seckillFallback")
public SeckillResult seckill(Long userId, Long productId) {
    // 正常逻辑
}

/**
 * 降级逻辑
 */
public SeckillResult seckillFallback(Long userId, Long productId, Exception e) {
    log.error("秒杀服务异常,降级处理", e);
    return SeckillResult.fail("系统繁忙,请稍后重试");
}

2. 动态开关

@Component
public class SeckillSwitch {
    
    @Value("${seckill.switch:on}")
    private boolean switchOn;
    
    public boolean isSwitchOn() {
        return switchOn;
    }
    
    /**
     * 手动关闭开关(通过配置中心动态下发)
     */
    public void setSwitchOn(boolean on) {
        this.switchOn = on;
    }
}
// 使用开关
if (!seckillSwitch.isSwitchOn()) {
    return SeckillResult.fail("活动已结束");
}

3. 排队系统

当Redis库存扣减成功后,如果数据库写不进去,不要让用户一直等,给他们一个”排队中”的状态,然后异步处理。


八、最后说几句

那次双11之后,我们团队复盘了整整一周。发现最大的问题不是技术,而是预案不足。我们没有考虑到流量洪峰的形状——不是一直高,而是突然一个尖峰。

后来我们做了几件事:

  1. 全链路压测。上线前模拟真实流量,找出瓶颈。
  2. 混沌工程。随机杀掉服务实例,检验系统的容错能力。
  3. 灰度发布。新功能先上10%的流量,观察无误再全量。

技术方案的优化是无止境的。读写分离、分库分表、缓存优化,这些只是基础。真正的核心是:了解你的业务,预测你的流量,做好最坏的打算

希望这篇能帮到你。如果有任何问题,欢迎讨论。