从零搭建10万日活架构:一个Windows系统管理工具的「逆袭之路」

0 阅读12分钟

从零搭建10万日活架构:一个Windows系统管理工具的「逆袭之路」

🔥 写在前面:很多程序员都有一个误区——"我不是架构师,不需要懂架构"。但当你写的工具从几个人用到几千人用,从本地运行到需要远程部署,从单机到集群,架构问题就会扑面而来

本文是我帮朋友优化一个浏览器HTTP摄像头权限配置工具的真实经历。从单机WinForm到支持10万日活的架构,踩过的坑比代码行数还多。


一、先说背景:我们遇到了什么问题?

1.1 原始架构:简单到不能再简单

┌─────────────────────────────────────────────────────────────┐
│                    原始架构(单机版)                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   用户请求 → HTTP摄像头服务(localhost:8090)→ 摄像头设备     │
│                     ↓                                        │
│              单机运行,Tomcat内置                          │
│                     ↓                                        │
│              直接读取注册表/摄像头驱动                      │
│                                                             │
│   问题:                                                    │
│   ├─ 一台电脑只能服务一个用户                               │
│   ├─ 没有缓存,重复请求重新解析                              │
│   ├─ 没有日志,出问题只能靠猜                               │
│   └─ 没有监控,不知道用户量级                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.2 第一波增长:问题来了

当用户量从100涨到1000的时候:

❌ 问题一:响应时间爆炸
├─ 每次请求都要查询摄像头状态
├─ Windows API调用本身就有50-100ms延迟
└─ 用户抱怨:"点一下等3秒,怎么用?"

❌ 问题二:服务器扛不住
├─ 单机Tomcat并发上限~200
├─ 峰值1000 QPS直接爆掉
└─ 服务崩溃重启,用户数据丢失

❌ 问题三:配置失效
├─ 改了Chrome配置,用户重启后还原
├─ 注册表写入成功,但浏览器不认
└─ 360/Edge多浏览器兼容问题

二、架构演进:从单机到10万日活

2.1 第一阶段优化:加缓存(QPS从200→2000)

问题分析

为什么慢?—— 每次请求都调Windows API
为什么崩?—— 没有缓存,请求堆积

解决方案:加Redis缓存

/**
 * 第一版缓存:简单粗暴
 * 把摄像头状态缓存起来,减少Windows API调用
 */
@Service
public class CameraService {

    @Autowired
    private RedisTemplate<String, CameraStatus> redis;

    @Autowired
    private WindowsCameraApi windowsApi;  // Windows原生API

    /**
     * 原始版本(慢)
     */
    public CameraStatus getStatus_original(String cameraId) {
        return windowsApi.getCameraStatus(cameraId);  // 每次都调API,50-100ms
    }

    /**
     * 优化版本(快)
     * 1. 先查Redis缓存(0.1ms)
     * 2. 缓存没有才调API
     * 3. 写入缓存,过期时间5秒
     */
    public CameraStatus getStatus_optimized(String cameraId) {
        String cacheKey = "camera:status:" + cameraId;

        // 1. 查缓存
        CameraStatus cached = redis.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }

        // 2. 缓存miss,调API
        CameraStatus status = windowsApi.getCameraStatus(cameraId);

        // 3. 写入缓存,过期时间5秒
        redis.opsForValue().set(cacheKey, status, 5, TimeUnit.SECONDS);

        return status;
    }
}

效果对比

┌─────────────────────────────────────────────────────────────────┐
│                   性能对比(本地压测)                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   优化前:                                                       │
│   ├─ 单次请求:80ms                                              │
│   ├─ 100并发:P99延迟 320ms                                       │
│   └─ QPS上限:~200                                                │
│                                                                 │
│   优化后(加缓存):                                              │
│   ├─ 单次请求:2ms(缓存命中)                                    │
│   ├─ 100并发:P99延迟 45ms                                        │
│   └─ QPS上限:~2000                                               │
│                                                                 │
│   提升:延迟降低 94%,QPS提升 10倍                                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

2.2 第二阶段优化:多级缓存 + 本地缓存(QPS 2000→8000)

新问题:Redis集群在极端情况下(如网络抖动)还是慢

解决方案:本地缓存 + Redis缓存 双保险

/**
 * 第二版缓存:本地缓存 + Redis缓存 多级缓存
 *
 * 为什么需要本地缓存?
 * - Redis还要网络往返(~1ms),本地缓存是内存(~0.01ms)
 * - 热点数据(如公共配置)99%都在本地命中
 * - 减轻Redis压力
 */
@Service
public class MultiLevelCacheService {

    // 本地缓存(Caffeine)- 进程内,最快
    private final Cache<String, CameraConfig> localCache =
        Caffeine.newBuilder()
            .maximumSize(1000)                    // 最多1000条
            .expireAfterWrite(2, TimeUnit.SECONDS) // 写入后2秒过期
            .recordStats()                         // 开启统计
            .build();

    // 分布式缓存(Redis)- 集群共享
    @Autowired
    private RedisTemplate<String, CameraConfig> redis;

    public CameraConfig getConfig(String browserType, String version) {
        String cacheKey = browserType + ":" + version;

        // 1. 先查本地缓存(最快)
        CameraConfig local = localCache.getIfPresent(cacheKey);
        if (local != null) {
            return local;
        }

        // 2. 本地没有,查Redis
        CameraConfig redisVal = redis.opsForValue().get(cacheKey);
        if (redisVal != null) {
            // 写入本地缓存
            localCache.put(cacheKey, redisVal);
            return redisVal;
        }

        // 3. Redis也没有,查数据库/配置中心
        CameraConfig config = loadFromSource(browserType, version);

        // 4. 回填两级缓存
        localCache.put(cacheKey, config);
        redis.opsForValue().set(cacheKey, config, 30, TimeUnit.SECONDS);

        return config;
    }
}

缓存命中率监控

// 监控缓存效果
public Map<String, Object> getCacheStats() {
    CacheStats stats = localCache.stats();

    return Map.of(
        "hitCount", stats.hitCount(),
        "missCount", stats.missCount(),
        "hitRate", stats.hitRate(),           // 本地命中率
        "evictionCount", stats.evictionCount(), // 淘汰次数
        "avgLoadTime", stats.averageLoadPenalty() + "ms"  // 平均加载时间
    );
}

2.3 第三阶段优化:异步 + 消息队列(QPS 8000→50000)

新问题:配置写入是同步的,高峰期堆积严重

解决方案:写入异步化,用Kafka削峰

/**
 * 第三版:写入异步化
 *
 * 原始流程(同步,用户等待):
 * 用户请求 → 写入注册表(200ms)→ 返回成功
 *
 * 优化流程(异步,用户快速返回):
 * 用户请求 → 发送Kafka消息 → 立即返回"处理中"
 *                      ↓
 *              Kafka消费者异步处理
 *                      ↓
 *              写入注册表 → 更新缓存 → 完成
 */
@Service
public class AsyncConfigService {

    @Autowired
    private KafkaTemplate<String, ConfigCommand> kafka;

    @Autowired
    private CameraConfigMapper configMapper;

    /**
     * 同步版本(慢,用户等200ms)
     */
    public Result applyConfig_sync(ConfigDTO dto) {
        writeToRegistry(dto);      // 200ms
        updateLocalCache(dto);      // 10ms
        return Result.success();    // 总计210ms
    }

    /**
     * 异步版本(快,用户等5ms)
     */
    public Result applyConfig_async(ConfigDTO dto) {
        // 1. 立即返回(只发消息,5ms)
        String messageId = UUID.randomUUID().toString();
        kafka.send("camera-config-topic", messageId, new ConfigCommand(dto))
             .addCallback(
                 success -> log.info("消息发送成功: {}", messageId),
                 fail -> log.error("消息发送失败: {}", messageId)
             );

        // 2. 立即返回"处理中"
        return Result.success("配置已提交,2秒后生效");
    }
}

/**
 * Kafka消费者异步处理
 */
@Component
public class ConfigConsumer {

    @KafkaListener(topics = "camera-config-topic", groupId = "config-group")
    public void onMessage(ConsumerRecord<String, ConfigCommand> record) {
        ConfigCommand cmd = record.value();

        try {
            // 1. 写入注册表(最慢,200ms)
            writeToRegistry(cmd);

            // 2. 更新Redis缓存
            updateRedisCache(cmd);

            // 3. 更新本地缓存
            updateLocalCache(cmd);

            // 4. 记录处理日志
            saveAuditLog(cmd);

            log.info("配置应用成功: {}", cmd.getConfigId());

        } catch (Exception e) {
            // 失败重试(最多3次)
            retryWithBackoff(cmd, 3);
        }
    }
}

Kafka vs RabbitMQ选型

┌─────────────────────────────────────────────────────────────────┐
│                   Kafka vs RabbitMQ 对比                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   维度           │  Kafka               │  RabbitMQ             │
│   ───────────────┼─────────────────────┼─────────────────────  │
│   吞吐量          │ 百万级/秒           │ 十万级/秒             │
│   延迟            │ 毫秒级              │ 微秒级                │
│   消息持久化       │ 支持(顺序写磁盘)   │ 支持(可选)          │
│   消息顺序         │ 同一Partition内有序  │ 队列内有序            │
│   消息回溯        │ 支持(从offset重读) │ 不支持               │
│   适用场景         │ 日志、大数据分析    │ 业务消息、事务       │
│   运维复杂度       │ 高(依赖ZooKeeper)  │ 低(单节点可用)     │
│                                                                 │
│   选型建议:                                                     │
│   ├─ 我们的场景(日志上报、配置下发):选Kafka                    │
│   └─ 交易类消息、事务消息:选RabbitMQ                            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

2.4 第四阶段优化:微服务拆分(支撑10万日活)

最终架构

┌─────────────────────────────────────────────────────────────────┐
│                    10万日活架构全景图                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   用户端                                                          │
│     ↓                                                             │
│   ┌─────────────┐                                                │
│   │   Nginx     │  负载均衡 + SSL卸载 + 静态资源                  │
│   │  (LVS备)    │  48G × 2台                                   │
│   └──────┬──────┘                                                │
│          ↓                                                       │
│   ┌─────────────┐                                                │
│   │   Gateway   │  统一入口 + 路由 + 限流                        │
│   │ Spring Cloud│  48G × 3台(= 注册中心集群)                  │
│   └──────┬──────┘                                                │
│          ↓                                                       │
│   ┌────────┴────────┐                                            │
│   ↓                  ↓                                           │
│ ┌──────────┐    ┌──────────┐                                    │
│ │ 设备管理服务 │   │  配置下发服务 │                              │
│ │ (读写分离) │   │ (Kafka异步) │                                │
│ └────┬─────┘    └────┬─────┘                                    │
│      ↓                ↓                                          │
│ ┌────────┐      ┌────────┐                                      │
│ │ MySQL  │      │ Redis   │                                     │
│ │ 主从   │      │ Cluster │                                     │
│ │ 36从 │      │ 6节点   │                                     │
│ └────────┘      └────────┘                                      │
│                                                                 │
│   监控层:Prometheus + Grafana + Loki                            │
│   日志层:ELK(Elasticsearch + Logstash + Kibana)               │
│   链路追踪:SkyWalking                                            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

各服务资源估算

┌─────────────────────────────────────────────────────────────────┐
│                   资源规划(10万日活)                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   假设条件:                                                     │
│   ├─ 日活10万,用户平均每天使用5次                               │
│   ├─ QPS = 100000 × 5 / 864006 QPS(平均值)                  │
│   ├─ 峰值QPS = 平均 × 1060 QPS(实际上更集中在晚上)          │
│   ├─ 页面浏览量PV = 100万/天                                    │
│                                                                 │
│   实际部署:                                                     │
│   ├─ Nginx:2核4G × 2台(Keepalived主备)                       │
│   ├─ Gateway:4核8G × 3台(Eureka集群)                         │
│   ├─ 设备服务:2核4G × 4台(轮询负载)                           │
│   ├─ 配置服务:2核4G × 2台(Kafka生产者)                       │
│   ├─ MySQL:16核64G × 3台(1主2从,读写分离)                   │
│   ├─ Redis:8核32G × 6台(Cluster模式,3分片×2副本)           │
│   ├─ Kafka:4核8G × 3台(ZooKeeper集群)                       │
│                                                                 │
│   成本估算(月费):                                             │
│   ├─ 云服务器:约 3000元/月                                     │
│   ├─ 数据库RDS:约 2000元/月                                     │
│   ├─ Redis集群:约 1500元/月                                    │
│   ├─ 消息队列:约 500元/月                                       │
│   └─ 合计:约 7000元/月                                         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

三、关键技术实现

3.1 Windows API调用封装

/**
 * Windows摄像头权限API封装
 * 解决:Chrome/Edge/360/Firefox多浏览器兼容
 */
@Service
public class WindowsCameraApi {

    /**
     * 获取摄像头列表
     * 调用Windows Media Foundation API
     */
    public List<CameraDevice> listCameras() {
        // JNA调用Windows原生API
        PointerByReference devices = new PointerByReference();

        int hr = WMFApi.INSTANCE.MFEnumDevices(
            MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
            devices
        );

        if (hr != 0) {
            throw new CameraApiException("枚举设备失败: " + hr);
        }

        // 解析设备列表...
        return parseDeviceList(devices.getValue());
    }

    /**
     * 检查摄像头权限
     * 跨浏览器兼容:分别检查各浏览器的注册表路径
     */
    public CameraPermission checkPermission(String cameraId, String browserType) {
        switch (browserType) {
            case "chrome":
                return checkChromePermission(cameraId);
            case "edge":
                return checkEdgePermission(cameraId);
            case "firefox":
                return checkFirefoxPermission(cameraId);
            case "360":
                return check360Permission(cameraId);
            default:
                throw new IllegalArgumentException("不支持的浏览器: " + browserType);
        }
    }

    /**
     * Chrome权限检查(HKCU注册表)
     */
    private CameraPermission checkChromePermission(String cameraId) {
        String keyPath = "HKCU\\Software\\Google\\Chrome\\PermissionBubbles";

        try {
            // 读取注册表
            String value = RegistryUtils.readValue(keyPath, cameraId);

            if (value == null) {
                return CameraPermission.NOT_CONFIGURED;
            }

            return "1".equals(value)
                ? CameraPermission.ALLOWED
                : CameraPermission.DENIED;

        } catch (RegistryException e) {
            log.warn("Chrome权限读取失败,尝试备用方案", e);
            return checkChromeFallback(cameraId);
        }
    }
}

/**
 * JNA接口定义
 */
public interface WMFApi extends StdCallLibrary {

    WMFApi INSTANCE = Native.load("mfplat.dll", WMFApi.class);

    /**
     * 枚举音视频设备
     */
    int MFEnumDevices(int flags, PointerByReference devices);

    /**
     * 释放设备枚举
     */
    void MFFreeMediaType(Pointer pMediaType);
}

3.2 注册表操作工具

/**
 * Windows注册表操作工具
 * 用于:写入摄像头权限配置
 */
@Component
public class RegistryUtils {

    private static final Logger log = LoggerFactory.getLogger(RegistryUtils.class);

    /**
     * 读取注册表值
     */
    public static String readValue(String keyPath, String valueName) {
        try {
            // 使用cmd命令读取注册表
            String cmd = String.format(
                "reg query \"%s\" /v %s", keyPath, valueName
            );

            Process process = Runtime.getRuntime().exec(cmd);
            String result = readProcessOutput(process);

            // 解析输出
            if (result.contains(valueName)) {
                String[] parts = result.split("\\s+");
                return parts[parts.length - 1];
            }

            return null;

        } catch (IOException e) {
            log.error("读取注册表失败: {}\\{}", keyPath, valueName, e);
            return null;
        }
    }

    /**
     * 写入注册表值
     */
    public static boolean writeValue(String keyPath, String valueName, String value) {
        try {
            // 先确保父级Key存在
            ensureKeyExists(keyPath);

            // 写入值
            String cmd = String.format(
                "reg add \"%s\" /v %s /t REG_SZ /d %s /f",
                keyPath, valueName, value
            );

            Process process = Runtime.getRuntime().exec(cmd);
            int exitCode = process.waitFor();

            if (exitCode == 0) {
                log.info("注册表写入成功: {}\\{} = {}", keyPath, valueName, value);
                return true;
            }

            return false;

        } catch (IOException | InterruptedException e) {
            log.error("写入注册表失败: {}\\{}", keyPath, valueName, e);
            return false;
        }
    }

    /**
     * 确保注册表Key存在
     */
    private static void ensureKeyExists(String keyPath) {
        try {
            String cmd = String.format("reg add \"%s\" /f", keyPath);
            Runtime.getRuntime().exec(cmd).waitFor();
        } catch (Exception e) {
            log.warn("创建注册表Key失败", e);
        }
    }

    /**
     * 读取进程输出
     */
    private static String readProcessOutput(Process process) throws IOException {
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()))) {
            StringBuilder output = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                output.append(line).append("\n");
            }
            return output.toString();
        }
    }
}

四、踩坑实录:这些坑让我失眠了3天

4.1 坑一:Kafka消息丢失

现象:用户反馈配置没生效,但后台日志显示"发送成功"

根因:Kafka异步发送 + 生产者acks配置不当

// ❌ 问题代码
kafkaTemplate.send("topic", message);  // fire-and-forget,不等确认

// ✅ 正确做法
ListenableFuture<SendResult<String, ConfigCommand>> future =
    kafkaTemplate.send("topic", message);

// 方式1:同步等待(推荐,用于关键消息)
try {
    SendResult<String, ConfigCommand> result = future.get(3, TimeUnit.SECONDS);
    log.info("消息发送成功,offset: {}", result.getRecordMetadata().offset());
} catch (Exception e) {
    // 发送失败,进入重试流程
    handleSendFailure(message, e);
}

// 方式2:异步回调
future.addCallback(
    success -> log.info("发送成功: {}", success.getRecordMetadata().offset()),
    failure -> {
        log.error("发送失败", failure);
        retryMessage(message);
    }
);

Kafka配置

spring:
  kafka:
    producer:
      # acks=1:leader确认就返回(快,可能丢)
      # acks=all:ISR全部确认才返回(慢,安全)
      acks: all

      # 重试次数(默认Integer.MAX_VALUE)
      retries: 3

      # 幂等性(保证不重复发送)
      enable-idempotence: true

      # 批次大小(16KB)
      batch-size: 16384

      #  linger.ms:等待多久凑够一个批次再发
      #  设为5ms可以提高吞吐量,但增加延迟
      linger-ms: 5

4.2 坑二:Redis Cluster脑裂

现象:Redis主节点宕机,从节点接管后数据丢失了10分钟

根因:Redis Cluster在网络分区时产生脑裂,多个主节点同时写入

# 观察Redis日志
# 找到 "CONTEXT: CLUSTERDOWN The cluster is not able to serve"

# 原因分析:
# 假设3节点Cluster,1个节点和另外2个节点网络断开
# 断开节点认为自己还是主节点,继续写入
# 另外2个节点选出新主节点
# 网络恢复后,原主节点变成从节点,数据被覆盖

解决方案

# Redis配置:防止脑裂
min-slaves-to-write: 2      # 至少2个从节点,主节点才接受写入
min-slaves-max-lag: 10      # 从节点延迟超过10秒,主节点拒绝写入

# 或者更激进:
# 主节点网络断开时,直接停止接受写入(降级为只读)

4.3 坑三:JVM内存泄漏

现象:服务运行3天后内存从2G涨到8G,然后OOM

根因:Kafka消费者每次都创建新的CameraDevice对象,没有释放

// ❌ 问题代码:每次创建新对象
@KafkaListener
public void consume(ConsumerRecord<String, byte[]> record) {
    // 反序列化
    CameraDevice device = new CameraDevice();  // 对象不断创建
    JSON.parseObject(record.value(), device.getClass());

    // 存入集合(导致内存泄漏)
    deviceCache.put(device.getId(), device);  // 不断堆积
}

// ✅ 正确做法:复用对象 + 定期清理
@Bean
public Cache<String, WeakReference<CameraDevice>> deviceCache =
    Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterAccess(5, TimeUnit.MINUTES)  // 5分钟未访问就清理
        .removalListener((key, value, cause) ->
            log.info("清理设备缓存: {} 原因: {}", key, cause))
        .build();

五、生产环境避坑清单

┌─────────────────────────────────────────────────────────────────────┐
           ⚠️  高并发架构避坑清单(真实踩坑汇总)                       
├─────────────────────────────────────────────────────────────────────┤
                                                                     
   不要这样设计:                                                   
  ─────────────────                                                   
  1. 单机跑所有服务                                                   
      扛不住并发,一挂全挂                                           
                                                                     
  2. Kafka消息fire-and-forget                                        
      网络抖动时消息丢失,用户数据丢失                                
                                                                     
  3. Redis不用连接池                                                 
      每次请求都新建连接,连接数爆炸                                 
                                                                     
  4. 不设置JVM堆大小                                                 
      容器环境默认堆太小,OOM                                       
                                                                     
  5. 缓存不过期                                                      
      数据一致性问题                                                
                                                                     
   正确做法:                                                       
  ─────────────────                                                   
  1. 按业务拆分微服务                                                
      独立部署,独立扩缩容                                          
                                                                     
  2. Kafka用同步发送+重试机制                                        
      关键消息必须等确认                                            
                                                                     
  3. Redis连接池用Jedis/Lettuce                                      
      JedisCluster/LettuceCluster自动管理连接                       
                                                                     
  4. JVM参数:-Xms=Xmx=4g(生产环境必须)                           
      预分配内存,避免运行时扩容                                     
                                                                     
  5. 缓存TTL + 主动失效                                              
      写入时主动删除缓存,而不是等过期                               
                                                                     
  📊 性能数据(我的实测):                                          
  ─────────────────                                                   
  单机  加Redis缓存  QPS: 200  2000(10倍)                       
  加本地缓存  QPS: 2000  8000(4倍)                               
  Kafka异步化  QPS: 8000  50000(6倍)                             
  微服务拆分  支持10万日活                                          
                                                                     
└─────────────────────────────────────────────────────────────────────┘

六、总结

┌─────────────────────────────────────────────────────────────────┐
│                   架构演进路线图                                    │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   单机版                                                           │
│     ↓                                                             │
│   +Redis缓存 → 支撑2000 QPS                                       │
│     ↓                                                             │
│   +本地缓存(Caffeine) → 支撑8000 QPS                              │
│     ↓                                                             │
│   +Kafka异步 → 支撑50000 QPS                                      │
│     ↓                                                             │
│   微服务拆分 → 支撑10万日活                                        │
│                                                                 │
│   核心经验:                                                       │
│   1. 先扛住,再优化(过早优化是万恶之源)                          │
│   2. 缓存是银弹,但不是万能药                                     │
│   3. 异步化是性能提升的终极大招                                   │
│   4. 监控比代码更重要                                             │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

💬 今日话题

你的系统目前是什么架构?遇到过哪些性能瓶颈?

欢迎评论区分享,我们一起探讨!

如果这篇文章对你有帮助,点赞 + 收藏是对我最大的支持!


原创不易,微信公众号禁止转载