1. 从登录开始:告别传统Session,拥抱Redis与JWT

做电商项目,尤其是像“黑马点评”这种高并发场景下的应用,第一道坎往往就是用户登录。你想想,成千上万的用户同时涌入,如果登录验证这块慢了或者崩了,后面的秒杀、下单都是空谈。我刚开始做项目那会儿,也是从最传统的Session方案入手,后来踩了不少坑,才一步步优化到现在的“Redis + JWT”组合拳。今天我就把这套实战经验掰开揉碎了讲给你听,保证你听完就能用上。

传统Session方案,简单来说,就是服务器给每个登录用户开一个“小房间”(Session),然后把房间钥匙(Session ID)通过Cookie发给用户。用户下次来,拿着钥匙开门,服务器就能认出他。听起来挺美好,对吧?但问题马上就来了:多服务器部署怎么办? 用户第一次请求打到服务器A,Session存在A上;第二次请求被负载均衡到服务器B,B不认识这把钥匙,用户就得重新登录。这就是经典的Session共享难题。以前我们得用Session复制或者搞个中央存储(比如数据库)来解决,但前者网络开销大,后者又增加了数据库压力,在高并发下都是性能瓶颈。

后来,我们引入了Redis。思路很简单:既然Session共享麻烦,那我们干脆不把用户信息存在应用服务器内存里了,而是统一存到一个高性能的、独立的内存数据库里。在“黑马点评”里,用户登录成功后,我们生成一个随机的Token(比如UUID),作为Redis的Key,把用户信息(比如用户ID、昵称)序列化成JSON作为Value存进去,并设置一个合理的过期时间(比如30分钟)。然后把这个Token返回给前端,前端后续请求就带着这个Token。服务器收到请求,用Token去Redis里查,查到就说明用户已登录,把用户信息拿出来用,同时刷新一下这个Key的过期时间(这叫“续期”);查不到,就拦截请求让用户去登录。

这套方案解决了Session共享的问题,性能也杠杠的。但用着用着,我又发现了新痛点:每次请求都要查一次Redis。虽然Redis很快,但毕竟是网络IO,对于超高并发的接口,这依然是一笔不小的开销。而且,服务端始终要维护这个Token和用户信息的映射关系。

于是,JWT(JSON Web Token)进入了我的视野。JWT是一种无状态令牌,它的核心思想是“自包含”。用户登录成功后,服务器用密钥生成一个字符串,这个字符串由三部分组成(Header.Payload.Signature),其中Payload部分就编码了用户ID等关键信息。服务器把这个字符串发给前端,前端存起来(比如localStorage)。以后每次请求,前端在HTTP Header(通常是Authorization: Bearer <token>)里带上这个JWT。服务器收到后,只需要用同样的密钥验证一下签名是否有效、令牌是否过期,就能直接解析出用户信息,完全不需要再去查询任何数据库或缓存

这简直是分布式系统的福音!服务扩容时再也不用操心状态同步了。在“黑马点评”的实践中,我们将登录流程做了融合优化:首次登录依然用Redis方案,因为要校验短信验证码、处理新用户注册等复杂逻辑。登录成功后,除了在Redis设置Token,同时生成一个短期有效的JWT(例如5分钟)返回给前端。前端在5分钟内,可以用这个JWT快速访问非核心的、读多写少的接口(比如浏览商品、查看评论),极大减轻了Redis的压力。对于写操作(如下单、支付)或需要绝对最新登录状态的请求,则强制走Redis Token校验流程。这种“Redis为主,JWT为辅”的混合模式,既保证了安全性和状态管理的灵活性,又利用JWT的无状态特性提升了系统的整体吞吐量。

2. 构建坚不可摧的登录拦截体系

光有登录验证还不够,如何优雅地拦截未登录请求、管理用户登录状态,是另一个技术活。在“黑马点评”里,我们设计了一套双重拦截器机制,这也是很多面试官喜欢深挖的点。

最开始,我们只用一个拦截器,专门拦截那些需要登录的路径(比如/order/**, /user/info)。这个拦截器从请求头里拿到Token,去Redis查用户信息,查到了就放行,查不到就返回401。看起来没问题,对吧?但实际运行中我们发现了一个体验上的大坑:如果用户登录后,长时间只浏览首页、商品页(这些是不需要拦截的公开页面),那么他的Token在Redis里到期失效了,他自己是不知道的。直到他想去下单,点击提交按钮时,才会被拦截器拦下,提示“请重新登录”。用户会觉得莫名其妙:“我明明登录了,怎么又要我登录?” 体验非常差。

问题的根源在于,Token的刷新(续期)动作,只发生在用户访问需要登录的路径时。为了解决这个问题,我们引入了第二个拦截器,也就是**“全局Token刷新拦截器”**。

我来详细说说这套流程:

  1. 拦截器1(Token刷新拦截器):配置为拦截所有路径(/**)。它的逻辑是:

    • 尝试从请求中获取Token。
    • 如果拿到了Token,就去Redis查询对应的用户信息。
    • 只要查到用户信息(不管当前请求路径是否需要登录),就刷新这个Token在Redis中的过期时间(例如,重新设置为30分钟)。
    • 把用户信息存入ThreadLocal,方便后续业务层使用。
    • 然后放行请求。 这个拦截器的核心目的就一个:保持活跃用户的登录状态。只要用户还在活动,他的Token就永远不会过期。
  2. 拦截器2(登录校验拦截器):只拦截那些明确需要登录的路径。它的逻辑很简单:

    • ThreadLocal中尝试获取用户信息(这个信息是拦截器1放进去的)。
    • 如果获取不到,说明用户未登录,直接返回401状态码并拦截。
    • 如果获取到了,说明用户已登录,放心地放行。

这样设计的好处非常明显:对于已登录用户,无论他访问什么页面,只要请求发出了,他的登录状态就会自动续期。只有当他关闭浏览器(或App)长时间不活动,Token才会因自然过期而失效。下次再打开时,才需要重新登录。这个设计极大地提升了用户体验,也让登录态的管理变得更加智能和自动化。

这里再提一下ThreadLocal,它相当于为每个请求线程提供了一个独立的“储物柜”,在拦截器里把用户信息存进去,在后续的Controller、Service层都能随时取出来用,用完了记得在拦截器afterCompletion里清理掉,防止内存泄漏。这是实现线程内数据共享的一个非常经典和实用的技巧。

3. 热点数据缓存:性能提升的“王牌”

在“黑马点评”这种电商场景里,商品信息、店铺详情、优惠券列表这些都是读远大于写的数据,而且经常被高频访问。如果每次请求都去查数据库,数据库分分钟被压垮。所以,缓存是必须的,而且要用好。但用缓存可不是简单的setget,里面门道很多,搞不好就是“缓存雪崩”、“缓存击穿”、“缓存穿透”三连击。

缓存更新策略,这是首先要确定的。我们用的是经典的 Cache Aside Pattern(旁路缓存)。口诀就是:“先更新数据库,再删除缓存”。为什么是删除缓存而不是更新缓存?因为更新缓存可能带来并发写和数据不一致的麻烦。假设一个商品价格被频繁更新,如果每次更新数据库后都去更新缓存,那么中间很多次缓存更新可能是无效的(还没人读,价格又变了)。直接删除缓存,等下次有请求来发现缓存为空(Cache Miss),再去查数据库并回填缓存,这样更简单有效。虽然会有一次Cache Miss的代价,但保证了数据的最终一致性。

接下来就是应对那三个“经典难题”了:

3.1 缓存穿透:当查询一个必然不存在的数据 比如,有人恶意攻击,用数据库里根本不存在的商品ID(如-1, 0, 非常大的数)疯狂请求。缓存里没有,请求就会穿透到数据库。解决方案我们用了两种:

  • 缓存空对象:即使数据库查不到,也在Redis里存一个空值(比如set key-null),并设置一个较短的过期时间(比如30秒)。下次同样的请求过来,直接返回空,保护数据库。这是我们在“黑马点评”里用的主要方法,实现简单。缺点是多占了一点内存,并且短期内可能返回空数据(在数据库新增该商品后的30秒内)。
  • 布隆过滤器:在查询缓存前,先经过一层布隆过滤器。它是一个很长的二进制向量,通过一系列哈希函数,把数据库里所有可能存在的Key映射到这个向量里。查询时,先用布隆过滤器判断这个Key是否存在。如果布隆过滤器说“不存在”,那这个Key一定不存在,直接返回,不用查缓存和数据库。如果布隆过滤器说“存在”,则这个Key可能存在(有极小的误判率),再去查缓存和数据库。这适合海量数据且不允许缓存空对象的场景。

3.2 缓存雪崩:大量缓存Key在同一时间失效 想象一下,你给所有商品数据都设置了2小时的TTL,结果凌晨2点,这些缓存同时失效。瞬间,所有请求涌向数据库,数据库可能直接宕机。解决办法:

  • 差异化过期时间:这是最基本也最有效的。在设置缓存TTL时,加一个随机值。比如,基础时间2小时,再加上一个0到300秒的随机数。这样就能避免大量缓存同时失效。
  • Redis高可用:主从集群、哨兵模式、Cluster集群搞起来,防止单点故障导致整个缓存服务不可用。
  • 多级缓存:这是我们的“大招”。除了Redis这层分布式缓存,我们在应用本地还加了一层Caffeine本地缓存。请求来了,先查本地内存(Caffeine),没有再去查Redis,再没有才查数据库。数据回填时,同时写入Caffeine和Redis。这样即使Redis集群出现问题,本地缓存还能扛一会儿,给运维争取恢复时间。Caffeine可以设置较小的容量和较短的过期时间(比如1分钟,1000个条目),主要用于应对极端情况下的热点数据访问。

3.3 缓存击穿:一个热点Key失效的瞬间 这是最危险的情况。某个“爆款”商品Key,在缓存过期的刹那,海量请求同时到来,全部去查数据库,就像一颗子弹击穿了缓存这层护甲。我们用了两种方案来防御:

  • 互斥锁(Mutex Lock):当第一个线程发现缓存失效后,它不去查数据库,而是先去获取一个Redis分布式锁(比如用SETNX命令)。拿到锁的线程,才有资格去查询数据库并重建缓存。其他没拿到锁的线程,则等待一小段时间(比如50毫秒),然后重试从缓存获取。这样就把对数据库的并行访问,变成了串行访问,保护了数据库。缺点是,在锁等待期间,性能会下降。

    // 伪代码示例:互斥锁思路
    public Shop queryWithMutex(Long id) {
        String key = "cache:shop:" + id;
        // 1. 从缓存查
        String shopJson = redisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson)) {
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        // 判断是否为空值(防穿透)
        if (shopJson != null) { // 说明是之前缓存的空对象""
            return null;
        }
        // 2. 缓存未命中,尝试获取锁
        String lockKey = "lock:shop:" + id;
        boolean isLock = tryLock(lockKey); // 实现tryLock,例如用SETNX
        if (!isLock) {
            // 3. 获取锁失败,休眠并重试
            Thread.sleep(50);
            return queryWithMutex(id); // 递归重试
        }
        // 4. 获取锁成功,再次检查缓存(Double Check)
        shopJson = redisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson)) {
            unlock(lockKey);
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        // 5. 查数据库
        Shop shop = getById(id);
        // 模拟重建缓存的耗时
        Thread.sleep(200);
        if (shop == null) {
            // 数据库也没有,缓存空对象防穿透
            redisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES);
            unlock(lockKey);
            return null;
        }
        // 6. 写入缓存
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES);
        // 7. 释放锁
        unlock(lockKey);
        return shop;
    }
    
  • 逻辑过期:我们不给热点Key设置物理TTL(让它永不过期),而是在存储的Value里,额外加一个逻辑过期时间字段。当线程查询缓存时,发现数据还在,但逻辑时间已过期,那么当前线程会去获取一个分布式锁,然后立刻返回旧的(已过期的)数据给用户,同时异步地开启一个新线程去执行数据库查询和缓存重建的任务。其他线程在此期间来访问,发现锁被占用了,也直接返回旧的过期数据。这样保证了服务的可用性(永远有数据返回,哪怕是稍旧的),实现了异步更新,性能最好。缺点是有一段时间会返回“脏数据”,需要业务能接受短时间的数据延迟。

    // 伪代码示例:逻辑过期思路
    @Data
    public class RedisData {
        private LocalDateTime expireTime; // 逻辑过期时间
        private Object data; // 存储的真实数据,如Shop对象
    }
    
    public Shop queryWithLogicalExpire(Long id) {
        String key = "cache:shop:" + id;
        // 1. 从缓存查
        String redisDataJson = redisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(redisDataJson)) {
            return null; // 缓存未预热,直接返回null
        }
        // 2. 反序列化,判断逻辑时间
        RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class);
        Shop shop = (Shop) redisData.getData();
        LocalDateTime expireTime = redisData.getExpireTime();
        // 3. 判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 未过期,直接返回
            return shop;
        }
        // 4. 已过期,尝试获取锁进行缓存重建
        String lockKey = "lock:shop:" + id;
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            // 5. 获取锁成功,开启独立线程异步重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库,重建缓存
                    this.saveShopToRedis(id, 20L); // 设置新的逻辑过期时间
                } catch (Exception e) {
                    log.error("缓存重建失败", e);
                } finally {
                    unlock(lockKey);
                }
            });
        }
        // 6. 无论是否获取到锁,都返回旧的商铺信息
        return shop;
    }
    

在“黑马点评”的实际项目中,对于一般的商品信息,我们采用 “缓存空对象 + 互斥锁” 的组合来应对穿透和击穿。而对于真正的热点爆款商品(比如参与秒杀的商品),我们会在系统启动时或活动开始前,就将其数据预热到缓存中,并采用 “逻辑过期” 方案,确保在秒杀期间,缓存永远不会失效,能够承受住洪峰般的请求。

4. 秒杀核心:分布式锁与异步化架构

秒杀是“黑马点评”项目的核心和高潮,也是最考验技术架构的地方。核心诉求就两点:第一,不能超卖(库存减成负数);第二,一人一单(防止黄脚本刷单)。在单机环境下,用synchronizedReentrantLock就能搞定。但在分布式集群环境下,多个JVM进程,每个进程都有自己的锁,它们之间互不知情,这就需要分布式锁来统一协调。

4.1 从简单的Redis锁到Redisson

我们最初尝试用Redis的SETNX(SET if Not eXists)命令自己实现分布式锁:抢锁就是SETNX lock:order 1,释放锁就是DEL lock:order。很快问题就暴露了:

  1. 不可重入:同一个线程如果多次获取同一把锁,会把自己锁死。
  2. 不可重试:获取锁失败就直接返回了,没有重试机制。
  3. 锁超时释放:为了防止死锁,我们给锁加了过期时间。但如果业务执行时间超过了锁的过期时间,锁会自动释放,可能导致其他线程进入,造成数据混乱。更危险的是,当前线程执行完去释放锁时,可能释放的是别的线程的锁。
  4. 主从一致性问题:在Redis主从架构下,锁信息写入主节点后,在同步到从节点之前主节点宕机了,从节点升级为主,但上面没有锁信息,锁就失效了。

为了解决这些问题,我们引入了 Redisson。它是一个在Redis基础上实现的Java客户端,提供了开箱即用的分布式锁实现。Redisson的锁解决了上述所有问题:

  • 可重入:利用Redis的Hash结构,Key是锁名,Field是客户端ID+线程ID,Value是重入次数。同一个线程再次加锁,只需增加重入次数即可。
  • 可重试:内部基于Pub/Sub机制,当锁被释放时,会通知其他等待的客户端进行重试抢锁。
  • 看门狗(Watchdog)自动续期:只要你没有指定锁的超时时间,Redisson会默认给锁设置30秒过期,并启动一个后台线程(看门狗),每隔10秒检查一下业务是否还在执行(锁是否还被持有),如果还在执行,就自动把锁的过期时间重置为30秒。这样只要业务没执行完,锁就不会因为超时而被意外释放。业务执行完,会显式释放锁,并停止看门狗。
  • 主从一致性问题:Redisson提供了RedLock(红锁)算法,但它实现复杂且性能有损。更常见的做法是使用Redis Cluster模式,利用其分片和复制机制来保证高可用。对于极致一致性的场景,Redisson的MultiLock(联锁)可以将一把锁同时写入多个独立的Redis主节点,只有所有节点都加锁成功才算成功,但这牺牲了性能和复杂度,需要权衡。

在“黑马点评”秒杀下单的核心逻辑中,我们用Redisson锁来保证“一人一单”的原子性检查。伪代码如下:

public Result seckillVoucher(Long voucherId) {
    // ... 查询优惠券、判断秒杀时间等前置校验 ...
    
    Long userId = UserHolder.getUser().getId();
    // 创建锁对象,锁的粒度细化到用户级别:order:userId
    RLock lock = redissonClient.getLock("lock:order:" + userId);
    // 尝试获取锁,waitTime=0(不等待),leaseTime=-1(使用看门狗自动续期)
    boolean isLock = lock.tryLock(0, -1, TimeUnit.SECONDS);
    if (!isLock) {
        // 获取锁失败,说明该用户正在下单中,直接返回错误或提示“请勿重复下单”
        return Result.fail("不允许重复下单!");
    }
    try {
        // 再次查询数据库,确认该用户是否已经下过单(Double Check)
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("您已经购买过了!");
        }
        // 扣减库存(乐观锁)
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0) // 乐观锁条件:库存大于0
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        // 创建订单
        VoucherOrder order = new VoucherOrder();
        // ... 设置订单信息,使用全局ID生成器生成订单ID ...
        save(order);
        return Result.ok(order.getId());
    } finally {
        // 释放锁
        lock.unlock();
    }
}

4.2 全局ID生成器:订单号的秘密

在高并发下生成订单号,不能用数据库自增ID(有规律、易猜测、单点瓶颈)。我们采用了 “时间戳 + 序列号” 的雪花算法变种。ID结构通常设计为64位Long型:

  • 1位符号位(始终为0)
  • 41位时间戳(毫秒级,可用约69年)
  • 10位工作机器ID(用于分布式部署)
  • 12位序列号(同一毫秒内的自增)

在“黑马点评”中,我们做了简化,利用Redis的自增原子性来生成序列号部分,确保分布式环境下不重复:

public class RedisIdWorker {
    // 起始时间戳(2024-01-01 00:00:00)
    private static final long BEGIN_TIMESTAMP = 1704067200L;
    // 序列号位数
    private static final int COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public long nextId(String keyPrefix) {
        // 1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2. 生成序列号(使用日期作为Key的一部分,方便统计且避免单Key无限增长)
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // Redis Key 示例:icr:order:2024:05:27
        long count = stringRedisTemplate.opsForValue()
                .increment("icr:" + keyPrefix + ":" + date);

        // 3. 拼接并返回(时间戳左移32位,然后与序列号进行或运算)
        return timestamp << COUNT_BITS | count;
    }
}

这样生成的ID趋势递增、基本无规律、性能极高,非常适合作为订单号。

4.3 终极优化:从同步秒杀到异步削峰

即使用上了分布式锁和乐观锁,同步秒杀流程依然有瓶颈:所有操作(查券、查库存、扣库存、查订单、创建订单)都在一个数据库事务里串行执行。用户从点击下单到收到结果,需要等待所有步骤完成,响应时间慢,且数据库连接占用时间长。

我们的优化思路是:将耗时且非核心的流程异步化。核心是“校验在前,异步落库”。

  1. 同步快速校验:用户点击秒杀,系统第一时间在Redis中完成资格校验。这包括:
    • 校验秒杀是否开始/结束(读Redis中的活动时间)。
    • 校验库存是否充足(使用Redis的DECR原子操作扣减预扣库存)。
    • 校验是否一人一单(使用Redis的SADD命令将用户ID加入已购集合,利用Set的唯一性)。 这些操作都在内存中完成,速度极快(毫秒级)。如果任何一项校验失败,立即返回失败结果。
  2. 异步创建订单:如果所有Redis校验通过,我们并不直接操作数据库,而是生成一个秒杀任务消息,放入RabbitMQ消息队列。然后立即给用户返回“秒杀排队中”或“秒杀成功,正在生成订单”的提示。
  3. 异步消费:后台有专门的消费者服务,从RabbitMQ中顺序取出消息,异步地、平稳地执行真正的数据库操作:创建订单、扣减真实数据库库存等。

这样一来,前端请求的响应时间变得极短(只需完成Redis操作),用户体验流畅。真正的压力从数据库转移到了消息队列。RabbitMQ起到了“削峰填谷”的作用:瞬间的海量请求被平滑成队列中的消息,消费者按照自己的能力匀速处理,数据库再也没有了瞬间的洪峰压力。即使消息处理稍有延迟,用户也能接受,因为他们已经拿到了“排队资格”。

这个架构的另一个巨大好处是解耦。下单业务、库存服务、订单服务可以通过消息队列进行通信,各司其职,系统扩展性和可维护性大大提升。在“黑马点评”中,我们正是通过这套“Redis校验 + RabbitMQ异步下单”的组合,稳稳地扛住了模拟的高并发秒杀压力。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐