分布式微服务系统架构第162集:如果是小米的过亿设备,如何搭建架构

加群联系作者vx:xiaoda0423

仓库地址:https://webvueblog.github.io/JavaPlusDoc/

https://1024bat.cn/

https://github.com/webVueBlog/fastapi_plus

https://webvueblog.github.io/JavaPlusDoc/

点击勘误issues,哪吒感谢大家的阅读

LettuceJedis 都是 Java 里用来连接 Redis 的客户端库 ,作用是让你的 Java 程序能去读写 Redis 数据。

但它们的设计和使用方式不太一样,主要区别在:


1️⃣ 基本定位

特性 Jedis Lettuce
实现原理 基于直连 Socket 基于 Netty(异步事件驱动)
线程安全 非线程安全 (一个 Jedis 对象只能一个线程用) 线程安全 (一个 Lettuce 连接可多线程共享)
连接模式 每次需要时获取连接,用完关闭/归还到连接池 推荐长连接,可一个连接支持并发命令
支持模式 单机、主从、哨兵、集群 单机、主从、哨兵、集群,全支持
同步/异步 只支持同步 支持 同步 + 异步 + Reactive(响应式)

2️⃣ 使用风格示例

Jedis 同步示例

go 复制代码
import redis.clients.jedis.Jedis;

public class JedisDemo {
    public static void main(String[] args) {
        try (Jedis jedis = new Jedis("localhost", 6379)) {
            jedis.set("name", "leo");
            String value = jedis.get("name");
            System.out.println(value);
        }
    }
}

缺点:Jedis 对象不能多线程共享,要靠连接池管理。


Lettuce 同步/异步示例

go 复制代码
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.api.async.RedisAsyncCommands;
import io.lettuce.core.api.StatefulRedisConnection;

public class LettuceDemo {
    public static void main(String[] args) {
        RedisClient client = RedisClient.create("redis://localhost:6379");
        StatefulRedisConnection<String, String> connection = client.connect();

        // 同步
        RedisCommands<String, String> sync = connection.sync();
        sync.set("name", "leo");
        System.out.println(sync.get("name"));

        // 异步
        RedisAsyncCommands<String, String> async = connection.async();
        async.get("name").thenAccept(System.out::println);

        connection.close();
        client.shutdown();
    }
}

优点:一个连接就能支持多线程,且异步性能好,特别适合高并发和 Spring WebFlux 这种响应式架构。


3️⃣ 总结选型建议

  • Jedis:简单、老牌、容易上手,但线程安全差,需要连接池管理;适合老项目或低并发场景。

  • Lettuce:新项目首选,线程安全,支持异步/响应式,Spring Boot 2.x 之后默认使用 Lettuce 代替 Jedis。


如果你是在 Spring Boot + Redis ,其实 spring-boot-starter-data-redis 默认就是 Lettuce,不用自己额外引 Jedis,除非你特别想换。


一、最小可用(单机→双机)

1) 拿一台服务器先跑通(PoC)

  • 一台机Nginx (stream) + Netty 网关 + Redis 单机

  • 用途:先把"设备能连、心跳续期、映射写Redis、Nginx转发"跑起来

Nginx(TCP 转发)

go 复制代码
# /etc/nginx/nginx.conf
stream {
  upstream iot_gateway {
    # 先单机,后面加节点
    server 127.0.0.1:9000;
  }
  server {
    listen 9000;                   # 设备连这个端口
    proxy_connect_timeout 5s;
    proxy_timeout 600s;
    proxy_pass iot_gateway;
  }
}

Netty(关键参数)

go 复制代码
new ServerBootstrap()
  .group(new NioEventLoopGroup(1), new NioEventLoopGroup())   // boss=1, worker=CPU核数
  .channel(NioServerSocketChannel.class)
  .option(ChannelOption.SO_BACKLOG, 4096)
  .childOption(ChannelOption.TCP_NODELAY, true)
  .childOption(ChannelOption.SO_KEEPALIVE, true)
  .childHandler(new IoTChannelInitializer(connectionManager, gatewayIp));

Redis(键设计)

  • device:gateway:{deviceId} = {gatewayIp},TTL=120s

  • 每次收到心跳/数据EXPIRE key 120

  • 网关宕机=不再续期 → TTL 到 → 键过期 → 设备重连被分配到存活节点


2) 升级成两台网关 + Nginx

  • 两台机网关A网关B

  • 一台机Nginx(对外),内部指向A/B

  • Redis:先仍单机(下一步再高可用)

Nginx(加一致性哈希)

go 复制代码
stream {
  upstream iot_gateway {
    hash $remote_addr consistent;   # 按来源IP粘性(足够简单)
    server 10.0.0.10:9000 max_fails=3 fail_timeout=30s; # 网关A
    server 10.0.0.11:9000 max_fails=3 fail_timeout=30s; # 网关B
  }
  server {
    listen 9000;
    proxy_connect_timeout 5s;
    proxy_timeout 600s;
    proxy_pass iot_gateway;
  }
}

说明:如果你的设备大量在同一运营商NAT 后面,$remote_addr 可能"很多设备同一IP"。到百万级 时建议升级为"按设备ID粘性"(可用 Nginx njs preread 提前读首包提取ID做hash,或让前置接入层读首包后按设备ID转发------这是进阶方案,先不急)。


二、Redis 高可用(两种选一个)

方案A:Sentinel(主从 + 自动切主)

  • 适合:容量要求一般、想简单点

  • Spring Boot 配置(节选):

go 复制代码
spring:
  redis:
    sentinel:
      master: mymaster
      nodes: 10.0.0.21:26379,10.0.0.22:26379,10.0.0.23:26379
    password: ${REDIS_AUTH:}

方案B:Cluster(分片 + 多副本)

  • 适合:设备多、QPS高、容量大
go 复制代码
spring:
  redis:
    cluster:
      nodes: 10.0.0.31:6379,10.0.0.32:6379,10.0.0.33:6379
      max-redirects: 3
    password: ${REDIS_AUTH:}

建议:Cluster 更长远 。你只需要把应用的 application.yml 切到 cluster profile 即可。


三、网关健康上报 + 映射原子写 + 踢掉旧连接(必要的三件套)

1) 健康上报(每10s)

go 复制代码
@Scheduled(fixedDelay = 10_000)
public void reportAlive() {
  redis.opsForValue().set("gateway:alive:" + gatewayIp,
                          String.valueOf(System.currentTimeMillis()),
                          Duration.ofSeconds(30));  // TTL 30s
}
  • 运维看 gateway:alive:* 就知道谁在线

2) 原子写映射(Lua)

  • 目的:把 device:gateway:{id}原子更新为当前网关IP,并返回旧IP(如果旧IP不同)

  • 用来跨网关"踢掉"旧连接,避免同一设备多地同时在线

Lua(简化版,够用):

go 复制代码
-- KEYS[1]=deviceKey, ARGV[1]=newIp, ARGV[2]=ttlSec
local old=redis.call('GET', KEYS[1])
if(old==ARGV[1]) then
  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2])); return {0}
end
redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
if(not old or old=='') then return {0} end
return {1, old}  -- 需要踢掉 old 网关的同设备连接

Java 调用:设备首包时执行;若返回{1, oldIp}→发消息给 oldIp 踢人(用 Redis Pub/Sub 或 Kafka)。

3) 踢掉旧连接(轻量版:Redis Pub/Sub)

  • 发布:{"type":"EVICT","target":"10.0.0.10","deviceId":"ABC123"}

  • 订阅端在目标网关 拿到消息后 channel.close() 清理本机旧连接


四、操作系统 & JVM & Netty 调优(保姆版)

Linux 内核(/etc/sysctl.conf)

go 复制代码
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.ip_local_port_range = 10000 65000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 3

应用:

go 复制代码
sysctl -p
ulimit -n 1048576   # 打开文件句柄,决定最大连接数上限

Netty 线程

  • bossGroup = 1

  • workerGroup = CPU核数 ~ 2×CPU核数(压测看情况)

  • Epoll (Linux):使用 EpollEventLoopGroup 更省CPU

JVM

  • 连接多时堆外内存DirectBuffer 占比高,Xms=Xmx 固定堆

  • GC:G1/ ZGC(二选一),尽量减少 stop-the-world

  • 监控:Prometheus + Grafana(曲线看连接数、堆、GC、CPU)


五、容量估算 & 扩容阈值

1) 经验值(保守)

  • 纯TCP长连接 + 轻协议:每连接 8--16KB RAM(含内核socket缓存 + 应用少量状态)

  • 1 台 16GB 机器:80k--120k 长连接(还要留出 JVM/系统/监控余量)

  • CPU 不是瓶颈时,内存和FD是主要限制

2) 你可以这样定阈值(示例)

  • 每台网关目标连接100k

  • 触发扩容:任一网关 10 分钟均值 > 80k 或 JVM老年代>70% 或 CPU>60%

  • 扩容操作:

  1. 新增一台网关(同样注册到 Nginx upstream)

  2. Nginx reload(无损)

  3. 设备会在后续重连时被一致性哈希分摊到新节点

  • 如果增长很快,一次性多上两台,少走回头路


六、你就照这个顺序做(最重要)

  1. 单机跑通(Nginx + 网关 + Redis 单机)

  2. 加一台网关 → Nginx upstream + consistent hash

  3. 把 Redis 换成 哨兵或集群

  4. 健康上报Lua 原子写跨网关踢人(三件套)

  5. 监控/告警(连接数、QPS、GC、负载)

  6. 设备量上来后:

  • 网关横向扩容(加机器)

  • Redis 走 Cluster

  • Nginx 多实例 + 前面再加 L4 SLB/Anycast(多机房可选)

  • 如果 $remote_addr 粘性不够,升级为按设备ID粘性(njs preread 或前置接入层)


七、你会遇到的坑(先知道=少走弯路)

  • NAT 同IP :很多设备同一个公网IP → $remote_addr 粘性会让单个网关压力偏高。可先调大权重或加节点,后续再上按设备ID粘性。

  • TTL 误判:心跳周期×3 做TTL,避免偶发丢包导致过期误切换。

  • 双连脏连接 :一定做Lua原子写 + 踢旧连接

  • FD 不够ulimit -n 不到位会很快耗尽。

  • 监控缺失:没有连接数/GC/CPU/Redis延迟监控,很难定位抖动。


八、要配置清单(你可以拷走用)

  • Nginx(stream)模板:上面给了

  • Redis:ClusterSentinelapplication.yml 模板:上面给了

  • 网关:ServerBootstrapChannelInitializerConnectionManager(含续期)、Lua原子写Pub/Sub踢人:上文都有

  • 系统:sysctl.conf + ulimit 详单


想不想我直接给你一个docker-compose 一键起:

  • nginx + redis-cluster + gatewayA/B + 压测客户端

    你本地跑起来立刻能看到:宕机迁移、TTL过期、踢旧连接 全流程。

    一句话,你说"要",我就给你打包好。

我给你一个可复用的设备端 自动重连 TCP 客户端代码(Java 版),适合和你的 Spring Boot + Netty 网关配合使用,支持以下特性:

  • 断线自动重连(指数退避重试)

  • 心跳保活

  • 消息收发

  • 支持百万级连接测试时的稳定性


1. 设备端自动重连 TCP 客户端代码

go 复制代码
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.timeout.IdleStateHandler;

import java.util.concurrent.TimeUnit;

public class DeviceClient {

    private final String host;
    private final int port;
    private final String deviceId;

    private EventLoopGroup group;
    private Bootstrap bootstrap;
    private volatile boolean reconnecting = false; // 避免重复重连

    public DeviceClient(String host, int port, String deviceId) {
        this.host = host;
        this.port = port;
        this.deviceId = deviceId;
        init();
    }

    private void init() {
        group = new NioEventLoopGroup(1); // 单线程模拟设备端
        bootstrap = new Bootstrap();
        bootstrap.group(group)
                .channel(NioSocketChannel.class)
                .option(ChannelOption.SO_KEEPALIVE, true)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ChannelPipeline p = ch.pipeline();
                        // 60 秒没发数据触发 Idle 事件
                        p.addLast(new IdleStateHandler(60, 30, 0, TimeUnit.SECONDS));
                        p.addLast(new DeviceClientHandler(DeviceClient.this, deviceId));
                    }
                });
    }

    public void connect() {
        bootstrap.connect(host, port).addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                System.out.println(deviceId + " 连接成功: " + host + ":" + port);
                reconnecting = false;
            } else {
                System.out.println(deviceId + " 连接失败,3秒后重试...");
                scheduleReconnect(3);
            }
        });
    }

    public void scheduleReconnect(int delaySeconds) {
        if (!reconnecting) {
            reconnecting = true;
            group.schedule(this::connect, delaySeconds, TimeUnit.SECONDS);
        }
    }

    public void sendMessage(String msg) {
        if (bootstrap != null && bootstrap.config().group() != null) {
            bootstrap.config().group().execute(() -> {
                try {
                    // 在 pipeline 中写数据
                    // 真实场景应保存 channel 引用
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
        }
    }

    public void shutdown() {
        group.shutdownGracefully();
    }

    public static void main(String[] args) throws InterruptedException {
        DeviceClient client = new DeviceClient("127.0.0.1", 9000, "DEVICE_0001");
        client.connect();
    }
}

2. 设备端 Handler(自动重连 + 心跳)

go 复制代码
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;

public class DeviceClientHandler extends ChannelInboundHandlerAdapter {

    private final DeviceClient client;
    private final String deviceId;

    public DeviceClientHandler(DeviceClient client, String deviceId) {
        this.client = client;
        this.deviceId = deviceId;
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 连接成功时发送注册信息
        String registerMsg = "REGISTER:" + deviceId;
        ctx.writeAndFlush(registerMsg);
        System.out.println(deviceId + " -> 注册信息已发送");
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        System.out.println(deviceId + " 连接断开,准备重连...");
        client.scheduleReconnect(3); // 延迟3秒重连
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent e = (IdleStateEvent) evt;
            if (e.state() == IdleState.WRITER_IDLE) {
                // 发送心跳包
                ctx.writeAndFlush("HEARTBEAT:" + deviceId);
                System.out.println(deviceId + " -> 发送心跳");
            }
        }
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        System.out.println(deviceId + " <- 收到消息: " + msg);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        System.err.println(deviceId + " 出错: " + cause.getMessage());
        ctx.close();
    }
}

3. 运行效果

假设你的 Netty 网关监听 9000 端口:

  • 设备启动后会连接网关,发送 REGISTER:DEVICE_0001

  • 每 30 秒发一次 HEARTBEAT:DEVICE_0001

  • 如果连接断开,3 秒后自动重连

  • 断网/宕机后自动恢复连接


4. 支持百万连接的做法

  • 真实测试要多线程批量创建 DeviceClient,每个模拟一个设备

  • 单机 Netty 最大连接数取决于 内存 + 文件描述符限制

  • 多台服务器时:

    • 设备连接时用 Nginx Stream + 一致性 Hash 分发到固定网关

    • 网关用 Redis 保存 deviceId → 网关IP 映射

    • 后端业务用 Kafka 转发,实现分布式消息处理

    • 网关挂了,Nginx 会把连接迁移到其他网关,设备端会自动重连