手写一个Nacos配置中心:搞懂长轮询推送机制(附完整源码)

起因

前几天写了一篇文章,讲我们项目里遇到的一个配置刷新问题。简单说就是用了 @RefreshScope 之后,简单的字符串、数字能自动刷新,但复杂的嵌套 Map 就不行了。后来我们自己监听了 EnvironmentChangeEvent,手动用 ResolvableType 绑定才搞定。

写完那篇文章之后,突然就很好奇:

  • Nacos 配置中心到底是怎么做到的? 我在 Nacos 控制台改个配置点发布,几秒钟后我的应用就自动生效了,这中间发生了什么?
  • 是服务端推送还是客户端拉取? 如果是推送,服务端怎么知道哪些客户端需要通知?如果是拉取,客户端怎么知道配置变了?
  • 为什么有时候感觉立刻就生效,有时候要等几秒? 这个延迟是怎么回事?

带着这些疑问,我决定自己动手写一个简化版的 Nacos 配置中心,看看能不能把这些机制都搞明白。

写之前先猜猜

我一开始以为 Nacos 用的是 WebSocket 或者 Server-Sent Events (SSE) 这种长连接方案。毕竟配置一变就要通知客户端,感觉上应该是服务端主动推送吧?

后来翻了翻资料才发现,人家用的是 HTTP 长轮询

什么是长轮询?简单说就是:

  • 客户端发一个 HTTP 请求给服务端
  • 服务端不立刻返回,而是把这个请求 hold 住
  • 如果配置变了,立刻返回变更信号
  • 如果一直没变,就等到超时(比如 30 秒)再返回空结果
  • 客户端收到响应后,等个几秒,再发下一轮请求

这个机制挺巧妙的,我们后面详细说。

长轮询 vs 长连接 vs 短连接

这里先把几个容易混淆的概念理清楚。

graph TB subgraph 短连接 A1[客户端] -->|请求| B1[服务端] B1 -->|立即响应| A1 A1 -.->|连接关闭| B1 end subgraph 长轮询 A2[客户端] -->|请求| B2[服务端] B2 -.->|hold 30秒| B2 B2 -->|有变更或超时| A2 A2 -.->|等2秒| A2 A2 -->|下一轮请求| B2 end subgraph 长连接_WebSocket A3[客户端] <-->|持续保持| B3[服务端] B3 -.->|任何时候都可推送| A3 end

短连接:

  • 发请求,立刻响应,连接关闭(或放回连接池)
  • 就是普通的 REST API

长连接(WebSocket/SSE):

  • 建立连接后一直保持
  • 服务端可以随时推送数据
  • 适合高频双向通信(聊天、游戏、股票行情)

长轮询:

  • 还是 HTTP 请求,但服务端会 hold 住一段时间
  • 本质上是"长时间的请求",不是"长时间的连接"
  • 适合低频推送(配置变更、通知)

Nacos 为什么用长轮询而不是 WebSocket?我猜测主要是:

  1. 配置变更频率低,不需要 WebSocket 的高频能力
  2. HTTP 兼容性好,容易穿透各种代理和网关
  3. 实现简单,服务端压力小

动手搞一个简化版

我们这个项目叫 surfing-nacos-refresh,目标是实现:

  • 客户端通过 HTTP 长轮询监听配置变更
  • 服务端 hold 住请求,有变更时立刻返回
  • 客户端拿到变更信号后,拉取最新配置
  • 自动刷新 Spring 的 Environment
  • 支持 @Value@ConfigurationProperties 热更新

整体架构

graph LR A[客户端应用] -->|长轮询监听| B[配置服务器] A -->|拉取配置| B A -->|更新Environment| A A -->|刷新Bean| A B -->|返回变更信号| A B -->|返回配置内容| A

服务端:怎么 hold 住请求

服务端最核心的就是这个 /listener 接口,客户端会不断发请求过来,我们要把请求挂起,等配置变更了再返回。

java 复制代码
@PostMapping("/v1/cs/configs/listener")
public void listener(
    @RequestParam("Listening-Configs") String listeningConfigs,
    HttpServletRequest request,
    HttpServletResponse response) throws IOException {
    
    log.info("[Listener] 收到订阅: {}", listeningConfigs);
    
    // 解析订阅串:dataId^2^group^2^md5^1
    List<ConfigKey> keys = parseListeningConfigs(listeningConfigs);
    
    // 先比较 md5,看看有没有变更
    List<ConfigKey> changedKeys = new ArrayList<>();
    for (ConfigKey key : keys) {
        String currentMd5 = getConfigMd5(key.getDataId(), key.getGroup());
        if (!Objects.equals(currentMd5, key.getMd5())) {
            changedKeys.add(key);
        }
    }
    
    // 有变更,立刻返回
    if (!changedKeys.isEmpty()) {
        log.info("[Listener] 检测到变更,立即返回: {}", changedKeys);
        String result = buildChangedResponse(changedKeys);
        response.getWriter().write(result);
        return;
    }
    
    // 没变更,挂起请求
    log.info("[Listener] 无变更,挂起请求");
    AsyncContext asyncContext = request.startAsync();
    asyncContext.setTimeout(0);  // 禁用容器默认超时
    
    addLongPollingClient(keys, asyncContext);
}

这里有几个关键点:

1. 先比较后挂起

服务端不是盲目挂起所有请求,而是先对比客户端发过来的 md5 和当前配置的 md5。如果不一样,说明客户端的配置已经过期了,立刻返回变更信号。

只有 md5 一致的情况下,才会挂起请求。

2. AsyncContext

这是 Servlet 3.0 提供的异步处理能力。普通的 Servlet 请求会占用一个工作线程,如果要 hold 30 秒,那这个线程就得等 30 秒。用了 AsyncContext 之后,工作线程可以释放,请求被转移到异步线程池处理。

java 复制代码
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0);  // 这个很重要!

setTimeout(0) 是禁用容器的默认超时机制,我们要用自己的超时调度。

3. 挂起的细节

java 复制代码
private void addLongPollingClient(List<ConfigKey> keys, AsyncContext asyncContext) {
    ClientSubscription sub = new ClientSubscription(keys, asyncContext);
    
    // 加到订阅列表
    for (ConfigKey key : keys) {
        subscribers.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>()).add(sub);
    }
    
    // 定时 29.5 秒后超时返回
    ScheduledFuture<?> timeoutTask = scheduler.schedule(() -> {
        try {
            log.info("[LongPolling] 超时,返回空字符串");
            asyncContext.getResponse().getWriter().write("");
            asyncContext.complete();
        } catch (Exception e) {
            log.error("[LongPolling] 超时处理失败", e);
        } finally {
            removeSubscriber(sub);  // 别忘了清理!
        }
    }, 29500, TimeUnit.MILLISECONDS);
    
    sub.setTimeoutTask(timeoutTask);
}

这里用了一个 ScheduledExecutorService 来管理超时。29.5 秒后,如果配置还没变,就返回空字符串,然后 complete 这个 AsyncContext。

4. 配置发布时的通知

当有人调用 /configs 接口发布新配置时,我们要立刻通知所有在等待的客户端:

java 复制代码
@PostMapping("/v1/cs/configs")
public String publishConfig(
    @RequestParam("dataId") String dataId,
    @RequestParam("group") String group,
    @RequestParam("content") String content) {
    
    // 保存配置
    saveConfig(dataId, group, content);
    
    // 通知所有订阅者
    notifyConfigChange(dataId, group);
    
    return "success";
}

private void notifyConfigChange(String dataId, String group) {
    ConfigKey key = new ConfigKey(dataId, group);
    List<ClientSubscription> subs = subscribers.get(key);
    
    if (subs != null) {
        log.info("[LongPolling] 通知 {} 个订阅者", subs.size());
        
        for (ClientSubscription sub : subs) {
            // 取消超时任务
            sub.cancelTimeout();
            
            // 返回变更信号
            try {
                String result = dataId + "^2^" + group + "^1";
                sub.getAsyncContext().getResponse().getWriter().write(result);
                sub.getAsyncContext().complete();
            } catch (Exception e) {
                log.error("[LongPolling] 通知失败", e);
            }
            
            // 清理订阅
            removeSubscriber(sub);
        }
        
        subscribers.remove(key);
    }
}

5. 订阅清理(重要!)

这个地方我当时踩了个坑。如果超时或者通知后不清理订阅,subscribers 这个 Map 会越来越大,而且会有很多已经 complete 的 AsyncContext 留在里面,再次通知时就会报错。

所以无论是超时还是通知,都要记得清理:

java 复制代码
private void removeSubscriber(ClientSubscription sub) {
    for (ConfigKey key : sub.getKeys()) {
        List<ClientSubscription> subs = subscribers.get(key);
        if (subs != null) {
            subs.remove(sub);
            if (subs.isEmpty()) {
                subscribers.remove(key);
            }
        }
    }
}

客户端:怎么发起长轮询

客户端这边用了一个定时器,每隔一段时间(retryInterval)就发起一次长轮询请求:

java 复制代码
public void start() {
    // 使用定时器,每 retryInterval 秒执行一次长轮询
    executor.scheduleWithFixedDelay(
        new LongPollingRunnable(), 
        0,  // 立即开始
        retryInterval,  // 间隔时间(默认2秒)
        TimeUnit.SECONDS
    );
}

class LongPollingRunnable implements Runnable {
    @Override
    public void run() {
        try {
            // 构造订阅串
            String listeningConfigs = buildListeningConfigs();
            System.out.println("[LongPolling] request Listening-Configs=" + listeningConfigs);
            
            RequestBody body = new FormBody.Builder()
                .add("Listening-Configs", listeningConfigs)
                .add("timeout", String.valueOf(timeout * 1000))
                .build();
                
            Request request = new Request.Builder()
                .url(serverAddr + "/v1/cs/configs/listener")
                .post(body)
                .build();
            
            // 这里会阻塞最多 30 秒
            try (Response resp = httpClient.newCall(request).execute()) {
                if (resp.isSuccessful()) {
                    String content = resp.body() != null ? resp.body().string() : "";
                    
                    if (StringUtils.isNotEmpty(content)) {
                        // 有变更
                        System.out.println("[LongPolling] 检测到变更: " + content);
                        List<ConfigKey> changedKeys = parseChangedConfigs(content);
                        for (ConfigKey key : changedKeys) {
                            CacheData cd = getCacheData(key.dataId, key.group);
                            if (cd != null) {
                                fetchFromServer(cd);  // 拉取最新配置
                            }
                        }
                    }
                    // 无变更的话,定时器会在 retryInterval 秒后自动触发下一轮
                }
            }
        } catch (Exception e) {
            // 出错了,定时器也会在 retryInterval 秒后重试
            System.err.println("[LongPolling] 请求失败: " + e.getMessage());
        }
    }
}

这里有几个时间上的设计:

sequenceDiagram participant Timer as 定时器 participant Client as 客户端 participant Server as 服务端 Note over Timer: retryInterval=2秒 Timer->>Client: 触发长轮询任务 Client->>Server: POST /listener (timeout=30s) Note over Server: hold 请求 alt 29.5s内有变更 Note over Server: 立即返回变更信号 Server->>Client: dataId^2^group^1 Client->>Server: GET /configs Server->>Client: 配置内容 Note over Client: 处理完成 Note over Timer: 等待2秒 Timer->>Client: 触发下一轮 else 29.5s超时 Server->>Client: 空字符串 Note over Client: 收到响应 Note over Timer: 等待2秒 Timer->>Client: 触发下一轮 end

关键的时间关系:

  • 服务端挂起超时:29.5 秒
  • 客户端请求超时:30 秒(OkHttp readTimeout)
  • 定时器触发间隔:2 秒(retryInterval)

这个设计很巧妙:

1. 为什么服务端是 29.5 秒?

因为要比客户端的 30 秒小一点,确保服务端先返回。如果服务端超时设置成 30 秒或更大,客户端可能先超时断开连接,服务端还在傻等。

2. 为什么用定时器而不是 while 循环?

用定时器(scheduleWithFixedDelay)的好处是:

  • 每次任务执行完(包括30秒的阻塞等待),等固定间隔(2秒)后才开始下一轮
  • 如果某次请求耗时长(比如网络慢),不会立刻发下一个请求
  • 更容易控制并发,不会因为异常导致疯狂重试

对比 while 循环:

java 复制代码
// while 循环需要手动 sleep
while (running) {
    doLongPolling();  // 可能耗时30秒
    Thread.sleep(2000);  // 再等2秒
}

// 定时器自动处理
executor.scheduleWithFixedDelay(() -> {
    doLongPolling();  // 可能耗时30秒
    // 任务结束后,定时器自动等2秒再触发下一轮
}, 0, 2, TimeUnit.SECONDS);

3. 为什么间隔是 2 秒?

这是个缓冲时间,避免客户端连续不断地发请求。实际的循环是:

  • 发请求 → 服务端 hold 29.5秒 → 返回 → 定时器等2秒 → 下一轮

所以如果配置一直不变,客户端的请求频率是:29.5秒 + 2秒 = 31.5秒一轮

拉取配置

客户端收到变更信号后,要再发一个 GET 请求拉取完整配置:

java 复制代码
private void handleConfigChange(String changedConfigs) {
    // 解析变更的 dataId 和 group
    String[] parts = changedConfigs.split("\\^1");
    for (String changed : parts) {
        String[] keyValue = changed.split("\\^2");
        if (keyValue.length >= 2) {
            String dataId = keyValue[0];
            String group = keyValue[1];
            
            // 拉取最新配置
            String newContent = fetchConfig(dataId, group);
            
            // 更新本地缓存
            updateLocalCache(dataId, group, newContent);
            
            // 触发刷新
            receiveConfigInfo(newContent);
        }
    }
}

private String fetchConfig(String dataId, String group) throws IOException {
    Request request = new Request.Builder()
        .url(serverAddr + "/v1/cs/configs?dataId=" + dataId + "&group=" + group)
        .get()
        .build();
    
    try (Response resp = httpClient.newCall(request).execute()) {
        return resp.body().string();
    }
}

这里是两阶段拉取:

  1. 长轮询只返回"哪些配置变了"
  2. 客户端再根据这个信号,去拉取具体内容

为什么不直接在长轮询里返回配置内容?因为:

  • 长轮询的响应要尽量小,快速返回
  • 配置内容可能很大,放在长轮询响应里会阻塞其他订阅者的通知
  • 分离变更信号和内容拉取,职责更清晰

刷新 Spring Environment

拿到新配置后,要更新 Spring 的 Environment,然后触发刷新事件:

java 复制代码
private void receiveConfigInfo(String content) {
    try {
        // 解析配置(支持 YAML 和 Properties)
        Properties props = parseConfig(content);
        
        // 更新 Environment
        ConfigurableEnvironment env = applicationContext.getEnvironment();
        MutablePropertySources sources = env.getPropertySources();
        
        // 创建新的 PropertySource
        PropertySource<?> propertySource = new PropertiesPropertySource(
            "nacos-config", props
        );
        
        // 替换或添加
        if (sources.contains("nacos-config")) {
            sources.replace("nacos-config", propertySource);
        } else {
            sources.addFirst(propertySource);
        }
        
        // 发布事件
        Set<String> keys = props.stringPropertyNames();
        applicationContext.publishEvent(new EnvironmentChangeEvent(keys));
        
        log.info("[Refresh] Environment 已更新,变更的 key: {}", keys);
    } catch (Exception e) {
        log.error("[Refresh] 配置刷新失败", e);
    }
}

EnvironmentChangeEvent 是 Spring Cloud 提供的,@RefreshScope 的 Bean 会监听这个事件,然后销毁旧实例,等下次使用时重新创建。

支持 @Value 刷新(我们加的)

但是光靠 @RefreshScope 还不够,我们希望普通的 @Value 也能刷新。所以我们加了一个 @Refresh 注解:

java 复制代码
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Refresh {
}

然后写了一个监听器,专门处理带 @Refresh 注解的类:

java 复制代码
@Component
public class ValueRefreshListener {
    
    @EventListener
    public void onEnvironmentChange(EnvironmentChangeEvent event) {
        // 找到所有带 @Refresh 的 Bean
        Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Refresh.class);
        
        for (Object bean : beans.values()) {
            refreshValueFields(bean, event.getKeys());
        }
    }
    
    private void refreshValueFields(Object bean, Set<String> changedKeys) {
        Class<?> clazz = bean.getClass();
        
        // 遍历所有字段
        for (Field field : clazz.getDeclaredFields()) {
            Value valueAnnotation = field.getAnnotation(Value.class);
            if (valueAnnotation != null) {
                String placeholder = valueAnnotation.value();
                String key = extractKey(placeholder);  // 提取 ${xxx} 里的 key
                
                if (changedKeys.contains(key)) {
                    // 从 Environment 获取最新值
                    Object newValue = resolveValue(placeholder, field.getType());
                    
                    // 反射设置
                    field.setAccessible(true);
                    try {
                        field.set(bean, newValue);
                        log.info("[Refresh] 更新字段: {}.{} = {}", 
                            clazz.getSimpleName(), field.getName(), newValue);
                    } catch (IllegalAccessException e) {
                        log.error("[Refresh] 字段更新失败", e);
                    }
                }
            }
        }
    }
}

这样一来,只要在类上加个 @Refresh 注解,里面的 @Value 字段就能自动刷新了:

java 复制代码
@Component
@Refresh  // 加这个!
public class FeatureConfig {
    @Value("${feature.new-algorithm:false}")
    private boolean useNewAlgorithm;
    
    @Value("${database.pool-size:10}")
    private int poolSize;
}

对比 Nacos,我们做了什么

保留的:

  • HTTP 长轮询机制(核心)
  • 两阶段拉取(变更信号 + 配置内容)
  • 先比较后挂起的逻辑
  • 超时时间设计(29.5s < 30s)

简化的:

  • 没有做服务发现(Nacos 2.x 用 gRPC)
  • 没有做配置加密、权限控制
  • 没有做灰度发布
  • 没有做多数据中心同步
  • 配置存储直接用内存(Nacos 用数据库)

增强的:

  • 加了 @Refresh 注解支持 @Value 刷新
  • 这块 Nacos 本身是通过 @RefreshScope 实现的,我们提供了另一种方案

踩过的坑

坑1:订阅清理

前面提到过,如果不清理订阅,subscribers 会越来越大。而且 AsyncContext complete 之后再调用会报错。我们的做法是:

  • 超时时清理
  • 通知时清理
  • 异常时也要在 finally 里清理

坑2:超时时间

一开始我把服务端超时设成 30 秒,客户端也是 30 秒,结果经常出现客户端超时断开,服务端还在等。后来改成 29.5 秒就好了。

而且网关的超时要设得更大,我们用的 Nginx,proxy_read_timeout 设成了 60 秒,确保不会被中间层断开。

坑3:线程池

AsyncContext 会用到异步线程池,如果配置不当,高并发时会出现拒绝。我们用的是 Tomcat,在 application.yml 里加了:

yaml 复制代码
server:
  tomcat:
    threads:
      max: 200
      min-spare: 10
    max-connections: 10000
    accept-count: 100

坑4:Environment 更新时机

有一次我们发现配置更新了,但 Bean 里的值还是旧的。后来发现是事件监听的顺序问题。EnvironmentChangeEventRefreshScopeRefreshedEvent 可能并发触发,如果你在两个事件里都做了操作,可能会有时序问题。

我们后来统一只监听 EnvironmentChangeEvent,问题就解决了。

整个流程串起来

最后画个完整的流程图,把服务端和客户端的交互都串起来:

sequenceDiagram participant App as 客户端应用 participant Long as 长轮询线程 participant Server as 配置服务器 participant Admin as 管理员 Note over App: 应用启动 App->>Long: 启动长轮询 loop 长轮询循环 Long->>Server: POST /listener (timeout=30s) Note over Server: 先比较 md5 alt md5 不一致 Server->>Long: 立即返回变更信号 else md5 一致 Note over Server: 挂起请求 (29.5s) par 等待期间 Admin->>Server: POST /configs (发布新配置) Server->>Server: 保存配置 Server->>Server: 取消超时任务 Server->>Long: 返回变更信号 and 如果超时 Note over Server: 29.5s 到 Server->>Long: 返回空字符串 end end alt 有变更 Long->>Server: GET /configs (拉取配置) Server->>Long: 返回配置内容 Long->>App: 更新 Environment App->>App: 发布 EnvironmentChangeEvent App->>App: 刷新 @RefreshScope Bean App->>App: 刷新 @Refresh 字段 Note over Long: 等待 2 秒 else 无变更 Note over Long: 等待 2 秒 end end

性能和稳定性

我们在本地跑了压测,模拟 100 个客户端同时长轮询,服务端没啥压力。因为请求被挂起后,工作线程就释放了,不会占用资源。

配置发布时的通知也很快,基本上是毫秒级。我们测试了同时通知 100 个客户端,耗时不到 50ms。

当然这只是个 Demo,生产环境还要考虑:

  • 配置持久化(数据库或文件)
  • 多实例部署时的配置同步
  • 客户端断线重连
  • 配置版本管理
  • 权限控制

但核心的长轮询机制已经跑通了,这是最重要的。

几个还没搞透的地方

关于泛型刷新:

前面那篇文章里提到的嵌套 Map 刷新问题,我在这个 Demo 里还没完全解决。如果你的配置类有复杂泛型,可能还是需要手动监听 EnvironmentChangeEvent,用 ResolvableType 绑定。

这块我还在研究,可能跟 Spring Boot 版本有关,也可能是 Binder 的实现细节。如果有大佬知道更好的方案,欢迎指点。

关于 @Async 方法:

如果你的服务里有 @Async 异步方法,或者自己创建的线程,这些地方拿到的配置对象可能不是最新的。因为 @RefreshScope 的代理只在 Spring 容器管理的调用链路上生效。

解决办法是在异步方法里重新获取 Bean,或者把配置值作为参数传进去。但这个我还没找到特别优雅的方案。

关于配置回滚:

如果新配置有问题,导致应用启动失败或者运行异常,怎么快速回滚?Nacos 有配置历史版本管理,我们这个 Demo 还没做。

这块可以考虑保留最近 N 个版本的配置,出问题时快速切回去。

写在最后

周五晚上下班回家就开始撸代码,搞到凌晨1点多。周六早上6点又爬起来继续干,前前后后投入了差不多10个小时。

但收获挺大的:

  1. 彻底搞懂了 Nacos 的长轮询机制
  2. 理解了 @RefreshScope 的原理
  3. 知道了配置刷新的完整链路
  4. 还顺便学了 AsyncContext 的用法

最重要的是,这些知识不是看视频看文章能学到的。只有自己动手写一遍,调试一遍,踩坑一遍,才能真正理解。

之前看 Nacos 源码,总觉得代码太多,看着看着就迷糊了。现在自己写了个简化版,再去看源码,就清晰多了。哪些是核心逻辑,哪些是边界处理,一目了然。

项目地址

GitHub: surfing-nacos-refresh

完整项目包含:

  • 服务端实现(配置存储、长轮询、推送通知)
  • 客户端实现(长轮询、配置拉取、Environment刷新)
  • Spring Boot Starter(开箱即用)
  • 可运行的 Demo(本地启动就能测试)

代码已经完全开源,clone 下来就能跑。总共也就几百行代码,但该有的功能都有了。

为什么选择开源?

这是个学习项目,目的是理解原理,不是造轮子。既然是学习,就应该分享出来,让更多人受益。

生产环境还是建议用官方的 Nacos,人家经过了大规模验证,功能更完善。

如果觉得有帮助:

  • 文章点个赞、收藏一下
  • 有问题欢迎提 issue 或者评论区交流

周末写代码+写文章确实挺累的,被老婆嫌弃了好几次"又在敲代码"。但看到能帮到大家,也值了。

下一步计划

下一篇文章打算写"Nacos 配置中心的常见面试问题",把这次学到的知识点整理成面试题的形式,比如:

  • 长轮询和长连接的区别?
  • 为什么服务端超时是 29.5 秒?
  • 配置刷新的完整流程是怎样的?
  • @RefreshScope 的原理是什么?
  • 如何保证配置刷新的线程安全?

敬请期待。


最后再问一句:你们项目里用的配置中心是什么?遇到过配置刷新的问题吗?

欢迎在评论区分享你的经验,我们一起交流学习。如果有什么疑问,也可以留言,我都会回复的。

周末愉快!

相关推荐
Cache技术分享1 小时前
254. Java 集合 - 使用 Lambda 表达式操作 Map 的值
前端·后端
用户345848285051 小时前
java除了`synchronized`关键字,还有哪些方式可以保证Java中的有序性?
后端
y***13641 小时前
【wiki知识库】07.用户管理后端SpringBoot部分
spring boot·后端·状态模式
CryptoPP1 小时前
使用 KLineChart 这个轻量级的前端图表库
服务器·开发语言·前端·windows·后端·golang
过客随尘2 小时前
Spring AOP以及事务详解(一)
spring boot·后端
武子康2 小时前
大数据-167 ELK Elastic Stack(ELK) 实战:架构要点、索引与排错清单
大数据·后端·elasticsearch
9号达人2 小时前
优惠系统演进:从"实时结算"到"所见即所得",前端传参真的鸡肋吗?
java·后端·面试