基于 Outbox 事务表 + Canal 监听+kafka+多级缓存:高并发社交关注系统全链路架构设计

在社交类业务系统中,用户关注与取关是访问量极高、并发压力大且对数据一致性要求严苛的核心场景,不仅需要应对用户高频点击、恶意刷接口等流量问题,还要解决消息丢失、重复消费、缓存与数据库数据偏差、粉丝关注计数不准等一系列线上常见难题。传统直接发送消息队列的开发模式,极易出现业务入库成功但消息投递失败,最终引发业务数据不一致的隐患。为此我基于实际业务场景,设计并落地了一套限流防护 + 多级缓存 + 本地事务事件表 + Canal 异步转发 的完整高并发社交关系架构,通过前置流量拦截、事务保障事件可靠落地、分层缓存提升查询性能、异步解耦完成后置业务处理,并搭配定时数据校验机制实现数据兜底纠偏,从请求入口到最终数据同步全链路完成优化,在保证系统高性能运行的同时,彻底解决幂等性、消息可靠性、缓存一致性等核心痛点,本文将完整拆解整套架构设计思路、核心代码实现与落地实践经验。

一、整体技术架构总览

分层架构设计:客户端层、API 接入层、业务服务层、多级缓存层、数据持久层、异步事件层、计数纠偏层 核心技术栈梳理:SpringBoot、Redis、Lua 限流、Caffeine 本地缓存、MyBatis、Outbox 事务表、Canal、Kafka

全链路核心流转简述:用户请求→鉴权限流→业务事务→库表写入→事件投递→异步消费→数据更新

二、API 接入层设计:

  1. 身份安全:JWT 登录鉴权,拦截未登录非法请求(采用双令牌认证,RAS加密算法)
  2. 基础防护:接口参数统一校验,过滤非法参数
  3. 流量防护:Redis Lua 令牌桶限流实现,控制单用户关注频次,防恶意刷接口

lua令牌桶设计,限流,防止一个用户指定时间内最多发多少请求

复制代码
初始桶中有100个令牌,以后每秒生成1个,如果一瞬间有100个请求,消耗完所有令牌,就会拒绝后续的请求同时如果桶满了,后续生成的令牌会直接丢弃
Lua 复制代码
private static final String TOKEN_BUCKET_LUA = """
            
            local key = KEYS[1]
            local capacity = tonumber(ARGV[1])
            local rate = tonumber(ARGV[2])
            local now = redis.call('TIME')[1]
            local last = redis.call('HGET', key, 'last')
            local tokens = redis.call('HGET', key, 'tokens')
            if not last then last = now; tokens = capacity end
            local elapsed = tonumber(now) - tonumber(last)
            local add = elapsed * rate
            tokens = math.min(capacity, tonumber(tokens) + add)
            if tokens < 1 then redis.call('HSET', key, 'last', now); redis.call('HSET', key, 'tokens', tokens); return 0 end
            tokens = tokens - 1
            redis.call('HSET', key, 'last', now)
            redis.call('HSET', key, 'tokens', tokens)
            redis.call('PEXPIRE', key, 60000)
            return 1
            """;

三、核心业务服务层设计

  1. 关注 / 取消关注核心业务逻辑拆分

关注操作:lua令牌桶限流,并将时间写入outbox表

java 复制代码
 @Override
    @Transactional
    public boolean follow(long fromUserId, long toUserId) {
        // Lua 脚本令牌桶限流--同一个用户1s内最多发起多少关注
        Long ok = redis.execute(tokenScript, List.of("rl:follow:" + fromUserId), "100", "1");
        if (ok == 0L) {
            return false;
        }

        //调用线程安全的随机数生成工具,生成一个超大随机数,作为数据库主键ID
        long id = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
        int inserted = mapper.insertFollowing(id, fromUserId, toUserId, 1);

        if (inserted > 0) {
            try {
                Long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
                String payload = objectMapper.writeValueAsString(new RelationEvent("FollowCreated", fromUserId, toUserId, id));
                outboxMapper.insert(outId, "following", id, "FollowCreated", payload);
            } catch (Exception ignored) {}

            return true;
        }
        return false;
    }

取关操作:写入outbox事件

java 复制代码
/**
     * 取消关注操作,并写入 Outbox 事件。
     * @param fromUserId 发起取消关注的用户ID
     * @param toUserId 被取消关注的用户ID
     * @return 是否取消成功
     */
    @Override
    @Transactional
    public boolean unfollow(long fromUserId, long toUserId) {
        int updated = mapper.cancelFollowing(fromUserId, toUserId);
        if (updated > 0) {
            try {
                Long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
                String payload = objectMapper.writeValueAsString(new RelationEvent("FollowCanceled", fromUserId, toUserId, null));
                outboxMapper.insert(outId, "following", null, "FollowCanceled", payload);
            } catch (Exception ignored) {}
            return true;
        }
        return false;
    }
  1. Spring 声明式事务管控:保证关系表与事件表写入原子性
  2. 分布式幂等设计:Redis 唯一键去重,杜绝 MQ 重复消费导致业务重复执行

查看是否是第一次,防止消息重复执行业务

java 复制代码
 String dk = "dedup:rel:" + evt.type() + ":" + evt.fromUserId() + ":" + evt.toUserId() + ":" + (evt.id() == null ? "0" : String.valueOf(evt.id()));
        Boolean first = redis.opsForValue().setIfAbsent(dk, "1", Duration.ofMinutes(10));
  1. 主键 ID 生成方案:高性能随机长整型替代自增主键

四、多级缓存架构:支撑高并发列表查询

  1. 三级缓存整体设计思路:本地缓存→Redis 缓存→数据库
  1. Caffeine 本地缓存:热门大 V 用户缓存,减轻 Redis 压力。头部大 V 用户的关注 / 粉丝列表会产生超高频率的查询流量 。如果所有请求都直接访问 Redis,甚至穿透到数据库,会造成极大的资源浪费,也容易在热点访问下产生性能瓶颈。因此,我在架构中引入 Caffeine 本地缓存 ,专门对大 V 热点数据进行前置缓存
java 复制代码
   this.flwsTopCache = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(10)).build();
        this.fansTopCache = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(10)).build();
  1. Redis ZSet 有序集合:存储关注 / 粉丝列表,按关注时间排序,自动 2 小时续期过期策略
java 复制代码
public void process(RelationEvent evt) {
        String dk = "dedup:rel:" + evt.type() + ":" + evt.fromUserId() + ":" + evt.toUserId() + ":" + (evt.id() == null ? "0" : String.valueOf(evt.id()));
        Boolean first = redis.opsForValue().setIfAbsent(dk, "1", Duration.ofMinutes(10));

        // 非首次(存在去重键)直接返回,保证消息幂等,防止网络波动,消费者重启,重平衡,重试机制
        if (first == null || !first) {
            return;
        }
        if ("FollowCreated".equals(evt.type())) {
            // 异步插入粉丝表
            mapper.insertFollower(evt.id(), evt.toUserId(), evt.fromUserId(), 1);
            long now = System.currentTimeMillis();

            // 更新关注表与粉丝表缓存:ZSet 按时间分数维护最近项,设置短 TTL 减少陈旧数据
            redis.opsForZSet().add("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId()), now);
            redis.opsForZSet().add("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId()), now);
            //使用redis.expire设置缓存过期时间
            redis.expire("uf:flws:" + evt.fromUserId(), Duration.ofHours(2));
            redis.expire("uf:fans:" + evt.toUserId(), Duration.ofHours(2));

            // 更新关注数与粉丝数
            userCounterService.incrementFollowings(evt.fromUserId(), 1);
            userCounterService.incrementFollowers(evt.toUserId(), 1);
        } else if ("FollowCanceled".equals(evt.type())) {
            mapper.cancelFollower(evt.toUserId(), evt.fromUserId());

            // 更新关注表与粉丝表缓存:移除 ZSet 项并刷新 TTL
            redis.opsForZSet().remove("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId()));
            redis.opsForZSet().remove("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId()));
            redis.expire("uf:flws:" + evt.fromUserId(), Duration.ofHours(2));
            redis.expire("uf:fans:" + evt.toUserId(), Duration.ofHours(2));

            // 更新关注数与粉丝数
            userCounterService.incrementFollowings(evt.fromUserId(), -1);
            userCounterService.incrementFollowers(evt.toUserId(), -1);
        }
    }
  1. Redis SDS 计数器:轻量化存储用户关注数、粉丝数,实现高速原子增减
  2. 缓存读写逻辑:查询优先走缓存、缓存击穿回源数据库、查询数据自动回填缓存

五、数据持久层设计

  1. 核心数据表结构设计
    • following 关注关系表(逻辑删除设计)
    • follower 粉丝关系表
    • outbox 事务事件表(核心事务消息载体)
    • user 用户基础信息表
  2. 逻辑删除优势:适配业务取关场景,摒弃物理删除
  3. Outbox 事务消息核心作用:解决本地事务与 MQ 消息一致性问题

六、异步事件通信核心链路

  1. 传统直发 MQ 弊端:事务不一致、消息丢失、耦合度高
  2. Outbox 模式的核心思想是将事件消息与业务数据纳入同一个本地事务,在更新业务数据的同时,向事件表插入一条消息,通过数据库事务原子性保证 "数据入库 + 事件落地" 要么全部成功,要么全部失败。再通过 Canal 监听表变化实现无侵入式消息投递,不依赖分布式事务、不依赖消息中间件可靠性,从架构层面彻底解决消息丢失、数据不一致、业务耦合等难题,实现轻量级、高可靠、可扩展的异步事件通信。
  3. Canal 监听机制:仅监听 outbox 表 INSERT/UPDATE 变更,过滤无用删除事件

创建Canalkafka桥接器,拉去未处理消息

java 复制代码
  // 创建 Canal 单实例连接器并建立连接
                connector = CanalConnectors.newSingleConnector(new InetSocketAddress(host, port), destination, username, password);
                log.info("Canal connecting to {}:{} dest={} user={} filter={}", host, port, destination, username, filter);
                connector.connect();
                // 订阅过滤表达式,仅拉取关心的表(如 outbox)
                connector.subscribe(filter);
                // 回滚到上次确认位点,保证一致性处理
                connector.rollback();
                log.info("Canal connected and subscribed: host={} port={} dest={} filter={} batchSize={} intervalMs={}ms", host, port, destination, filter, batchSize, intervalMs);
                while (running) {
                    // 拉取一批未确认消息(不自动 ack)
                    Message message = connector.getWithoutAck(batchSize);
  1. Canal 数据组装转发:解析库表变更数据,封装统一 JSON 格式推送 Kafka
java 复制代码
try {
            //把kafka消息转换成多行数据表
            List<JsonNode> rows = OutboxMessageUtil.extractRows(objectMapper, message);
            if (rows.isEmpty()) {
                ack.acknowledge();
                return;
            }
            for (JsonNode row : rows) {
                JsonNode payloadNode = row.get("payload");
                if (payloadNode == null) {
                    continue;
                }
                //将json转换成要读的字符串
                RelationEvent evt = objectMapper.readValue(payloadNode.asText(), RelationEvent.class);
                processor.process(evt);
            }
            ack.acknowledge();
        } catch (Exception ignored) {}
    }
  1. Kafka 消费者消费流程:事件接收→幂等二次校验→执行后置业务

写入Redis,写入follower表

  1. 消费后置业务:更新缓存列表、维护用户计数、同步上下游业务数据

七、用户计数精准保障体系

  1. 实时计数更新:业务侧 + 消费侧双端维护计数
  2. 定时采样校验机制:定时核对缓存计数与数据库真实关系数量
  3. 自动数据纠偏修复:解决缓存失效、消息丢失导致计数错乱问题

八、业务核心问题解决方案

  1. 解决用户频繁重复点击关注 采用Redis Lua 令牌桶接口限流 做前置流量拦截,搭配Redis 唯一维度键实现分布式幂等双重防护,从请求入口与业务执行两层杜绝重复关注操作,避免无效请求冲击业务逻辑。

  2. 解决关注与取关场景缓存数据不一致用户执行关注、取消关注操作时,实时刷新对应 Redis ZSet 有序集合缓存过期时间,依托自动续期策略保障热数据有效留存,同时同步增删缓存内关系数据,彻底抹平缓存与业务状态偏差。

  3. 解决异步业务消息丢失、投递不可靠问题 摒弃直连 MQ 易出现的事务不一致问题,基于Outbox 本地事务事件表实现事务内事件落地,再通过 Canal 监听事件表变更结合 Kafka 完成异步转发,构建零丢失、高可靠的事件推送链路。

  4. 解决粉丝、关注列表深分页查询卡顿优先使用 Redis ZSet 有序集合实现分页查询,依托集合天然排序特性适配按时间倒序业务需求,仅缓存未命中、深分页场景下才回源数据库查询,大幅优化分页查询响应速度。

  5. 解决高并发流量下数据库访问压力过大 搭建Caffeine 本地热点缓存 + Redis 分布式缓存 + MySQL 数据库三级缓存架构,由上至下层层拦截用户查询流量,绝大部分热点请求直接走缓存响应,极大降低数据库查询与写入压力。

九、架构优势总结

  1. 高可用依托 Spring 事务保障业务数据写入原子性,结合 Outbox+Canal 构建可靠事件链路,既保证库表数据不会出现错乱偏差,又能实现业务事件百分百不丢失,整体业务流转稳定可靠。

    高性能搭建本地缓存 + Redis 分布式缓存 + 数据库三级缓存体系,依靠多级缓存完成流量削峰与热点数据承载,日常绝大多数查询请求直接命中缓存响应,极少流量穿透至数据库,接口响应速度与系统吞吐能力大幅提升。

    高扩展采用事件驱动架构实现业务完全解耦,核心关注取关逻辑与下游计数、数据同步等后置业务彻底隔离,后续新增业务消费场景,仅需新增消费者即可接入,无需改动核心业务代码,业务拓展灵活便捷。

    易维护整体采用分层清晰的模块化架构,各层级职责明确,线上问题可快速定位溯源;同时内置定时采样校验与自动纠偏机制,具备数据自愈修复能力,大幅降低后期运维与故障排查成本。

相关推荐
phltxy2 小时前
Redis集群:分布式高可用存储方案
数据库·redis·分布式
二宝哥2 小时前
大数据之安装zookeeper
大数据·分布式·zookeeper
xG8XPvV5d3 小时前
Kafka重平衡机制深度解析
分布式·kafka
敖正炀3 小时前
云原生持续交付:GitOps 与渐进式发布
分布式·架构
weixin_553654483 小时前
如何看待 2026 年 Google I/O 大会发布的 Gemini Spark?
大数据·人工智能·分布式·spark
heimeiyingwang4 小时前
【架构实战】分布式ID生成:雪花算法与业务ID设计
分布式·算法·架构
Jackyzhe4 小时前
从零学习Kafka:调优
分布式·学习·kafka
Jackyzhe16 小时前
从零学习Kafka:消费者组重平衡
分布式·学习·kafka