OpenResty、Tengine、NginxOpenResty官网地址：https://openresty.org/cn/OpenResty® 是一个基于 Nginx 与 Lua 的高性能 Web 平台，其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。OpenResty® 通过汇聚各种设计精良的 Nginx 模块（主要由 OpenResty 团队自主开发），从而将 Nginx 有效地变成一个强大的通用 Web 应用平台。这样，Web 开发人员和系统工程师可以使用 Lua 脚本语言调动 Nginx 支持的各种 C 以及 Lua 模块，快速构造出足以胜任 10K 乃至 1000K 以上单机并发连接的高性能 Web 应用系统。OpenResty® 的目标是让你的Web服务直接跑在 Nginx 服务内部，充分利用 Nginx 的非阻塞 I/O 模型，不仅仅对 HTTP 客户端请求,甚至于对远程后端诸如 MySQL、PostgreSQL、Memcached 以及 Redis 等都进行一致的高性能响应OpenResty其实就是一个基于Nginx的Web平台，但它最牛的地方在于集成了LuaJIT和很多精心编写的Lua库。简单来说，就是让你可以在Nginx里面写Lua代码，这听起来可能没什么，但用过的人都知道这有多香。比如，你产品经理突然要求在用户访问某个页面时，根据用户的地理位置显示不同的内容。如果用传统的方式，要么改后端代码重新发布，要么搭建一套复杂的CDN规则。但用OpenResty，我直接在nginx.conf里写了几行Lua代码就搞定了：location /api/content { access_by_lua_block { local ip = ngx.var.remote_addr local geo = require "resty.geoip" local country = geo.lookup(ip) if country == "CN" then ngx.var.backend = "china_backend" else ngx.var.backend = "global_backend" end } proxy_pass http://$backend; }这种灵活性是原生Nginx根本做不到的。你想想，如果每次这种小需求都要动用整个开发流程，那得多麻烦。OpenResty的安装也很简单，在Rocky Linux release 8.10上直接用yum就行：sudo yum install yum-utils sudo yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo sudo yum install openresty装完之后，你会发现它的目录结构和Nginx差不多，配置文件还是在/usr/local/openresty/nginx/conf/nginx.conf。但是多了很多Lua相关的模块，比如lua-resty-redis、lua-resty-mysql这些，基本上常用的都给你准备好了。#这是我修改后的文件 [root@webtest ~]# cat /usr/local/openresty/nginx/conf/nginx.conf worker_processes 1; error_log logs/error.log; events { worker_connections 1024; } http { server { listen 8080; location / { default_type text/html; content_by_lua_block { ngx.say(" hello, world ") } } } }Tengine官网：https://tengine.taobao.org/Tengine是阿里巴巴开源的Web服务器，基于Nginx开发。说到阿里的技术，那肯定是在高并发场景下被虐过千百遍才开源出来的，所以在性能和稳定性方面确实有两把刷子。安装官网下载编译安装包，上传到服务器[root@webtest ~]# tar -xf tengine-3.1.0.tar.gz [root@webtest ~]# cd tengine-3.1.0 [root@webtest ~]# ./configure [root@webtest ~]# make [root@webtest ~]# make installTengine最让我印象深刻的是它的动态模块加载功能。以前用Nginx，如果要加个模块，必须重新编译整个服务器，这在生产环境简直是噩梦。但Tengine可以动态加载模块，就像这样：dso { load ngx_http_lua_module.so; load ngx_http_geoip_module.so; }重启服务就能生效，不用重新编译。这个功能在我们需要快速响应业务需求的时候特别有用。还有一个让我很喜欢的功能是Tengine的健康检查。原生Nginx的upstream健康检查功能比较弱，基本上就是被动检查，后端服务挂了还要等用户请求失败才知道。但Tengine提供了主动健康检查：upstream backend { server 192.168.1.10:8080; server 192.168.1.11:8080; check interval=3000 rise=2 fall=5 timeout=1000 type=http; check_http_send "HEAD / HTTP/1.0\r\n\r\n"; check_http_expect_alive http_2xx http_3xx; }这样配置后，Tengine会每3秒主动检查一次后端服务器的健康状态，发现问题立即摘除，用户基本感知不到。实际使用用OpenResty做API网关，用Tengine做静态资源服务器和负载均衡器。OpenResty常用模块参考：https://www.cnblogs.com/phpworld/p/10969297.html用OpenResty做API网关，比如我们需要做接口限流，传统方式要么在应用层做，要么用Redis + Lua脚本。但OpenResty直接内置了限流模块：location /api/ { access_by_lua_block { local limit_req = require "resty.limit.req" local lim, err = limit_req.new("my_limit_req_store", 200, 100) if not lim then ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err) return ngx.exit(500) end local key = ngx.var.binary_remote_addr local delay, err = lim:incoming(key, true) if not delay then if err == "rejected" then return ngx.exit(503) end end } proxy_pass http://backend; }这样就实现了每个IP每秒最多200个请求，突发100个请求的限流策略。不过OpenResty也有一些坑，主要是Lua代码的调试比较麻烦。不像应用代码可以打断点单步调试，Lua代码基本上只能靠日志调试。我一般会在关键位置加上这样的日志：ngx.log(ngx.ERR, "debug: variable value is ", ngx.var.some_variable)然后在error.log里查看输出。Tengine的话，我主要用它做七层负载均衡。它的负载均衡算法比Nginx丰富一些，支持一致性哈希、最少连接数等多种算法：upstream backend { consistent_hash $request_uri; server 192.168.1.10:8080 weight=3; server 192.168.1.11:8080 weight=2; server 192.168.1.12:8080 weight=1; }这种一致性哈希在缓存场景下特别有用，可以保证相同的请求总是路由到同一台服务器。选择建议选择建议的话，我觉得主要看使用场景：如果你需要在Web服务器层面做很多业务逻辑处理，比如API网关、动态内容生成、复杂的访问控制等，那OpenResty是首选。它的Lua生态很丰富，基本上你能想到的功能都有现成的库。如果你主要做负载均衡和静态资源服务，对稳定性和性能要求比较高，那Tengine是个不错的选择。特别是在高并发场景下，Tengine的表现确实比原生Nginx要好一些。当然，如果你的需求比较简单，就是做个简单的反向代理或者静态文件服务，那原生Nginx也完全够用，没必要为了用新技术而用新技术。注意细节部署这两个服务器的时候，有几个地方需要注意。OpenResty的话，记得要合理设置Lua代码缓存。在开发环境可以关闭缓存方便调试：lua_code_cache off;但在生产环境一定要开启，否则性能会很差。还有就是要注意Lua代码的内存使用。OpenResty使用LuaJIT，内存管理和标准Lua有些不同。如果你的Lua代码里有大量的字符串操作或者表操作，要注意及时释放内存：-- 及时设置为nil释放内存 local big_table = {} -- ... 使用big_table big_table = nil collectgarbage()Tengine的话，要注意动态模块的加载顺序。有些模块之间有依赖关系，加载顺序错了会导致启动失败。一般来说，基础模块要先加载，业务模块后加载。监控方面，两者都支持Nginx的标准监控接口，可以用nginx-module-vts或者stub_status模块。我一般会配置一个内部监控接口：location /nginx_status { allow 127.0.0.1; allow 10.0.0.0/8; deny all; stub_status on; }然后用Prometheus + Grafana做监控大盘，效果还不错。
数据库访问五大核心技术简介在项目开发时，数据库访问技术是必不可少的，它直接决定了数据持久化的效率、开发维护的便捷性以及系统整体的性能表现。本文对比五种主流数据库访问技术：JOOQ 以类型安全的 SQL 和数据库优先设计著称，适合复杂查询与性能敏感场景，但需接受代码生成和商业授权限制；MyBatis 通过轻量级框架平衡了 SQL 控制与开发效率，支持动态 SQL 和多数据库适配，但需手动管理 SQL 与事务，类型安全较弱。此外，其他常见技术如 JDBC（原生但冗余）、Hibernate/JPA（全自动化 ORM 但学习曲线陡峭）、Spring Data JDBC/JPA（简化 CRUD 但灵活性受限）也各有适用场景。JDBC技术JDBC（Java数据库连接）是Java中为数据库连接提供基础支持的核心API。它是一个底层API，可对SQL执行和结果处理提供直接控制。可以把JDBC看作是Java数据库编程领域的“汇编语言”——功能强大，但需要更多手动操作。如下示例：private final DataSource dataSource ; public User queryByJdbc(Long id) { String sql = "SELECT * FROM a_user WHERE id = ?"; try (Connection conn = this.dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setLong(1, id); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { return mapRowToUser(rs); } } } return null; } private User mapRowToUser(ResultSet rs) throws SQLException { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); // ... return user; }JDBC 适用场景性能关键型应用：需要最大程度控制 SQL 执行，以优化性能复杂查询：ORM 框架难以表达的复杂 SQL 查询遗留系统集成：需要与现有存储过程（Stored Procedures）交互简单数据访问的微服务：数据访问模式简单，无需 ORM 框架的额外开销。学习目的：理解数据库底层原理和 JDBC 核心机制JDBC 优势最大性能控制：直接执行 SQL，避免 ORM 框架的额外开销完整 SQL 功能支持：可使用数据库特有的优化和功能（如特定语法、索引提示）轻量级：内存占用小，启动速度快行为可预测：编写的 SQL 会被精确执行，无 ORM 的隐式行为通用兼容性：适用于所有支持 JDBC 的数据库（MySQL、Oracle、PostgreSQL 等）JDBC 劣势冗余代码多：需要手动处理连接、语句（Statement）和结果集（ResultSet）的创建与关闭手动对象映射：需自行将数据库行（Row）转换为 Java 对象（POJO）SQL 注入风险：若未正确使用参数化查询（PreparedStatement），易受攻击数据库移植性问题：原生 SQL 可能在不同数据库间不兼容（如分页语法差异）无内置缓存：需自行实现缓存策略（如使用 Redis 或本地缓存）注：Spring中，JdbcTemplate与JdbcClient（Spring 6+）通过模板化设计，将原生JDBC的冗余代码量降低70%，实现高效数据库访问。Hibernate技术Hibernate 是一个成熟的对象关系映射（ORM）框架，它通过面向对象编程的方式抽象化数据库操作。该框架能够自动处理 Java 对象与数据库表之间的映射关系，为面向对象开发者提供了一种更符合自然编程习惯的模型。private final SessionFactory sessionFactory ; public User queryByHibernate(Long id) { try(Session session = sessionFactory.openSession()) { User user = session.find(User.class, id) ; return user ; } }我们还可以使用HQL语句进行查询User user = session.createQuery("FROM User WHERE id = ?1", User.class) .setParameter(1, id, Long.class) .getSingleResult() ;Hibernate适用场景具备复杂实体间关系的复杂领域模型需要高级缓存和延迟加载策略的应用程序跨数据库兼容性需求熟悉 ORM 概念且愿意投入时间学习 Hibernate 的团队开发速度比精细调优性能更重要的应用程序Hibernate 优势自动对象映射：无需手动处理 ResultSet强大的缓存机制：内置一级和二级缓存延迟加载：仅在需要时加载数据，提升性能数据库无关性：最小代码修改即可切换数据库高级功能：条件查询、批量处理、连接池成熟的生态系统：丰富的文档和社区支持Hibernate 劣势学习曲线陡峭：配置和概念复杂（会话、事务、缓存）性能开销：ORM 层增加计算成本N+1 查询问题：配置不当可能导致低效查询内存占用高：相对来说对象图和缓存可能消耗大量内存Spring Data JPA技术Spring Data JPA 是基于 JPA（Java 持久化 API）构建的高层抽象框架，通常以 Hibernate 作为底层实现。它通过基于存储库（Repository）的数据访问方式，遵循“约定优于配置”原则，极大减少了样板代码的编写。// 实体定义 @Entity @Table(name = "a_user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id ; private String name ; // 其它字段 // getters, setters } // 定义Repository public interface UserRepository extends JpaRepository<User, Long> { } // Service private final UserRepository userRepository ; public User queryById(Long id) { return this.userRepository.findById(id).orElse(null); }Spring Data JPA 使用场景基于 Spring 的应用，希望采用一致的编程模型需要快速原型开发或实现标准 CRUD 操作的场景数据访问模式复杂度为简单至中等的项目团队更倾向于“约定优于配置”的开发方式Spring Data JPA 优势极简样板代码：通过 Repository 接口自动生成实现约定式查询：方法名自动转换为数据库查询深度整合 Spring 生态：与 Spring Boot、Security 等无缝集成多数据存储支持：轻松切换 JPA、MongoDB、Redis 等存储方案内置分页排序：直接支持常见操作，无需额外开发持续活跃开发：定期更新并新增功能Spring Data JPA 劣势查询控制受限：复杂查询可能需要自定义实现强依赖 Spring：与 Spring 框架高度耦合“魔法”行为：方法名约定可能导致混淆和错误性能开销：在 JPA/Hibernate 之上增加抽象层灵活性较低：针对特定场景的优化难度较大JOOQ技术JOOQ 采用了一种独特的方式：通过流畅的 API（Fluent API）生成类型安全的 SQL 语句。它将 SQL 视为一等语言，同时提供编译时检查和代码补全等优势。JOOQ 会根据数据库架构自动生成 Java 代码，确保应用代码与数据库结构始终保持同步。首先，引入依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jooq</artifactId> </dependency>接下来，配置编译插件，该插件会生成对应表的类信息<plugin> <groupId>org.jooq</groupId> <artifactId>jooq-codegen-maven</artifactId> <executions> <execution> <id>generate-jooq-classes</id> <phase>generate-sources</phase> <goals> <goal>generate</goal> </goals> </execution> </executions> <dependencies> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies> <configuration> <jdbc> <driver>com.mysql.cj.jdbc.Driver</driver> <url> jdbc:mysql://localhost:3306/tx?serverTimezone=GMT%2B8&useSSL=false&characterEncoding=UTF-8&rewriteBatchedStatements=true&cachePrepStmts=true </url> <user>root</user> <password>xxxooo</password> </jdbc> <generator> <database> <name>org.jooq.meta.mysql.MySQLDatabase</name> <includes>a_user</includes> <excludes></excludes> <inputSchema>tx</inputSchema> </database> <target> <packageName>com.pack.db.generated.jooq</packageName> <directory>src/main/java</directory> </target> <generate> <pojos>true</pojos> <daos>false</daos> <records>false</records> <javaTimeTypes>true</javaTimeTypes> </generate> </generator> </configuration> </plugin>编译后，将生成包结构。最后，我们就可以基于生成的这些类进行相关的操作了，如果你设置生成了DAO，那么就更简单了，直接通过DAO进行操作。如下示例，通过DSL操作：private final DSLContext dsl ; public AUser queryByJooq(Long id) { return dsl.selectFrom(A_USER) .where(A_USER.ID.eq(id)) .fetchOneInto(AUser.class); }JOOQ 适用场景复杂分析查询和报表应用数据库优先（Database-first）开发模式需要精细控制 SQL 但又不愿放弃类型安全的应用拥有强大 SQL 专业知识的团队JOOQ 优势类型安全的 SQL：编译时检查可防止 SQL 语法错误数据库优先模式：生成的代码与模式变更保持同步完整 SQL 支持：可访问高级数据库特性与函数卓越性能：与原生 JDBC 相比开销极小强大工具链：IDE 支持自动补全与重构多数据库支持：兼容多种数据库系统JOOQ 劣势代码生成依赖：需要构建时进行模式自省商业授权限制：专业功能需付费许可学习曲线：API 区别于标准 ORM 框架模式耦合：数据库模式变更需重新生成代码对象映射有限：自动化程度低于传统 ORM构建复杂度：需额外配置代码生成步骤MyBatis技术MyBatis 是一款优秀的持久层框架，它支持自定义 SQL、存储过程以及高级映射。MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作。MyBatis 可以通过简单的 XML 或注解来配置和映射原始类型、接口和 Java POJO（Plain Old Java Objects，普通老式 Java 对象）为数据库中的记录。首先，引入依赖<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.5</version> </dependency>接下来，简单配置mybatis: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl type-aliases-package: com.pack.db.domain最后，定义Mapper@Select("SELECT * FROM a_user WHERE id = #{id}") @Results({ @Result(property = "id", column = "id"), @Result(property = "name", column = "name"), @Result(property = "phone", column = "phone"), @Result(property = "idNo", column = "id_no"), @Result(property = "age", column = "age"), @Result(property = "email", column = "email") }) User queryById(@Param("id") Long id);你也可以通过XML定义具体的SQL。private final UserMapper userMapper ; public User queryByMyBatis(Long id) { return this.userMapper.queryById(id) ; }MyBatis 适用场景需要灵活控制 SQL：项目需要高度定制化 SQL 查询，且希望保持对 SQL 的直接控制复杂存储过程或动态 SQL：需要频繁调用数据库存储过程，或动态生成 SQL 语句多数据库兼容性：项目需支持多种数据库，且希望减少数据库方言差异带来的问题MyBatis 优势SQL 灵活性：直接编写 SQL，支持复杂查询、动态 SQL 和存储过程调用轻量级：无侵入式设计，学习成本低于完整 ORM 框架（如 Hibernate）性能可控：减少 ORM 的隐式查询和缓存开销，适合性能敏感场景结果集映射：支持灵活的 Java 对象与数据库表字段映射（如 XML 或注解配置）多数据库支持：通过方言配置适配不同数据库的 SQL 语法差异社区生态：成熟稳定，拥有大量第三方插件和工具支持MyBatis 劣势手动管理 SQL：需手动编写和维护大量 SQL，增加开发工作量类型安全较弱：SQL 与 Java 代码分离，编译时无法检查字段名或表名错误事务管理复杂：需显式配置事务（如 Spring 集成可缓解此问题）动态 SQL 繁琐：复杂动态条件需使用 <if>、<choose> 等标签，代码可读性下降缓存机制有限：内置一级缓存较弱，二级缓存需额外配置且易引发脏数据问题XML 配置冗余：大量 SQL 定义在 XML 文件中，维护成本较高（注解方式可部分缓解）
SpringBoot Jar包冲突在线检测1. 痛点背景在 Spring Boot 项目开发和运维中，Jar 包冲突是让开发者最头疼的问题之一：常见冲突场景类重复：不同依赖引入了相同的类，启动时报 ClassCastException 或 NoSuchMethodError版本冲突：同一个库的不同版本混用，行为不一致，线上才暴露问题日志混乱：SLF4J + Logback + Log4j 多个实现共存，日志输出异常驱动重复：MySQL 5.x 和 8.x 驱动同时存在，连接异常现有方案的局限性• mvn dependency:tree：只能编译期分析，无法反映运行时 classpath• IDE 插件：需要人工操作，无法自动化集成• 第三方工具：过重，难以嵌入 Spring Boot 应用考虑引入一个轻量、可嵌入、运行时可见的 Jar 包冲突检测工具。2. 技术方案设计核心架构运行时扫描 → 冲突检测 → 配置化建议 → Web 可视化 ↓ ↓ ↓ ClassLoader 规则引擎 模板系统 适配器 智能分析 变量替换 关键技术点1. 多环境 ClassLoader 适配public List<URL> getClasspathUrls() { ClassLoaderclassLoader= Thread.currentThread().getContextClassLoader(); List<URL> urls = newArrayList<>(); // 遍历所有 ClassLoader 层级 ClassLoadercurrent= classLoader; while (current != null) { if (current instanceof URLClassLoader urlClassLoader) { urls.addAll(Arrays.asList(urlClassLoader.getURLs())); } current = current.getParent(); } // Spring Boot LaunchedURLClassLoader 特殊处理 if (classLoader.getClass().getName().contains("LaunchedURLClassLoader")) { urls.addAll(extractFromLaunchedClassLoader(classLoader)); } return urls.stream().distinct().toList(); }2. 三维冲突检测算法// 类重复检测 Map<String, List<JarInfo>> classToJarsMap = newHashMap<>(); for (JarInfo jar : jars) { for (String className : jar.getClasses()) { classToJarsMap.computeIfAbsent(className, k -> newArrayList<>()).add(jar); } } // 版本冲突检测 Map<String, List<JarInfo>> nameToJarsMap = jars.stream() .collect(Collectors.groupingBy(JarInfo::getName)); // JAR 重复检测（基于签名） Map<String, List<JarInfo>> signatureMap = jars.stream() .collect(Collectors.groupingBy(this::generateJarSignature));3. 配置化规则引擎完全摒弃硬编码，通过 YAML 配置定义所有规则：conflict: advisor: rules: slf4j-logging: patterns: [".*slf4j.*", ".*logback.*", ".*log4j.*"] severity:HIGH advice:|3. 核心实现Jar包扫描器支持开发环境和生产环境的智能扫描：@Component publicclassJarScanner { public List<JarInfo> scanJars() { List<JarInfo> jars = newArrayList<>(); List<URL> urls = classLoaderAdapter.getClasspathUrls(); for (URL url : urls) { Stringpath= url.getPath(); if (shouldExclude(path)) continue; if (path.endsWith(".jar")) { // 扫描 JAR 文件 jars.add(scanJarFile(url)); } elseif (path.contains("target/classes")) { // 扫描开发环境类目录 jars.add(scanClassesDirectory(url)); } } return jars; } private JarInfo scanJarFile(URL url) { try (JarFilejar=newJarFile(newFile(url.toURI()))) { JarInfojarInfo=newJarInfo(); jarInfo.setName(extractJarName(jar.getName())); jarInfo.setVersion(extractVersion(jar)); // 扫描所有类文件 List<String> classes = jar.stream() .filter(entry -> entry.getName().endsWith(".class")) .map(entry -> entry.getName() .replace("/", ".") .replace(".class", "")) .toList(); jarInfo.setClasses(classes); return jarInfo; } catch (Exception e) { logger.warn("Failed to scan jar: {}", url, e); returnnull; } } }配置化建议生成器核心亮点：零硬编码，完全配置驱动@Component @ConfigurationProperties(prefix = "conflict.advisor") publicclassConflictAdvisor { private Map<String, RuleDefinition> rules = newHashMap<>(); private List<SeverityRule> severityRules = newArrayList<>(); publicvoidgenerateAdvice(List<ConflictInfo> conflicts) { for (ConflictInfo conflict : conflicts) { Stringidentifier= extractIdentifier(conflict); // 查找匹配的规则 for (RuleDefinition rule : rules.values()) { if (rule.matches(identifier)) { conflict.setSeverity(rule.getSeverity()); conflict.setAdvice(formatAdvice(rule.getAdvice(), conflict)); break; } } } } private String formatAdvice(String template, ConflictInfo conflict) { Map<String, String> variables = buildVariables(conflict); Stringresult= template; for (Map.Entry<String, String> entry : variables.entrySet()) { result = result.replace("${" + entry.getKey() + "}", entry.getValue()); } return result; } // 支持的模板变量 private Map<String, String> buildVariables(ConflictInfo conflict) { Map<String, String> variables = newHashMap<>(); variables.put("className", conflict.getClassName()); variables.put("conflictType", getConflictTypeText(conflict.getType())); variables.put("jarCount", String.valueOf(conflict.getConflictingJars().size())); variables.put("jars", conflict.getConflictingJars().stream() .map(jar -> jar.getName() + ":" + jar.getVersion()) .collect(Collectors.joining(", "))); variables.put("jarList", conflict.getConflictingJars().stream() .map(jar -> jar.getName() + ":" + jar.getVersion()) .collect(Collectors.joining("\n"))); return variables; } }前端界面 冲突详情 类名/Jar包名 严重程度 修复建议 4. 配置化规则系统规则定义语法conflict: advisor: rules: # 规则名称 database-driver: # 匹配模式（支持正则表达式） patterns: -".*mysql.*" -".*postgresql.*" -".*Driver.*" # 严重程度 severity:CRITICAL # 建议模板（支持变量替换） advice:|支持的模板变量变量名说明示例${className}冲突的类名或JAR名org.slf4j.Logger${conflictType}冲突类型类重复、版本冲突${jarCount}冲突JAR包数量3${jars}JAR包列表（逗号分隔）slf4j-api:1.7.36, slf4j-api:2.0.9${jarList}JAR包列表（换行分隔）用于详细展示${versions}版本列表1.7.36, 2.0.9严重程度规则支持多维度匹配条件：severity-rules: # 关键组件 - 严重 -patterns: [".*logger.*", ".*driver.*", ".*datasource.*"] severity:CRITICAL conflict-types: [CLASS_DUPLICATE, VERSION_CONFLICT] # 框架组件 - 高 -patterns: [".*spring.*", ".*hibernate.*"] severity:HIGH conflict-types: [VERSION_CONFLICT] # 大量冲突 - 中等 -min-jar-count:4 severity:MEDIUM conflict-types: [CLASS_DUPLICATE]5. 实战效果展示检测结果示例假设项目中存在以下冲突：依赖配置：<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.16</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-core</artifactId> <version>5.8.19</version> </dependency>检测结果：{ "conflicts":[ { "className":"cn.hutool.core.util.StrUtil", "type":"CLASS_DUPLICATE", "severity":"MEDIUM", "conflictingJars":[ {"name":"hutool-all","version":"5.8.16"}, {"name":"hutool-core","version":"5.8.19"} ], "advice":"工具库冲突...\n解决方案：\n1. 选择一个 hutool 版本\n2. 排除传递依赖..." } ], "summary":{ "totalJars":45, "conflictCount":1, "scanTimeMs":127 } } #### Web 界面效果 **概览面板**：总 JAR 数、冲突数量、扫描耗时 **严重程度分布**：CRITICAL/HIGH/MEDIUM/LOW 分类统计 **详细列表**：类名、冲突类型、涉及 JAR、修复建议 ### 6. 企业级应用场景 #### 开发阶段集成 @ComponentpublicclassConflictDetectionStartupRunnerimplementsCommandLineRunner { @Override publicvoidrun(String... args)throws Exception { if (isDevelopmentEnvironment()) { ScanResultresult= performConflictScan(); if (result.getConflicts().size() > 0) { logger.warn("发现 {} 个依赖冲突，建议访问 http://localhost:8080 查看详情", result.getConflicts().size()); } } }} #### CI/CD 流水线集成 !/bin/bash在 CI 阶段运行冲突检测java -jar conflict-detector.jar --mode=ci --output=report.json检查冲突数量CONFLICTS=$(cat report.json | jq '.summary.conflictCount')if [ $CONFLICTS -gt 0 ]; thenecho "发现 $CONFLICTS 个依赖冲突，请检查报告" exit 1fi ### 7.总结 本工具通过配置化规则和运行时扫描，实现了对 Jar 包冲突的自动检测和修复建议。无论开发环境还是生产环境，都可以直观地看到冲突详情，并及时处理。 https://github.com/yuboon/java-examples/tree/master/springboot-jar-conflict
高性能短链构建短链必须性这里列举3个原因来讲一下短链存在的必要性。相对安全短链不容易暴露访问参数，生成方式可以完全迎合短信平台的规则，能够有效地规避关键词、域名屏蔽等风险，而原始 URL地址，很可能因为包含特殊字符被短信系统误判，导致链接无法跳转。平台限制短信发送平台有字数限制，在整条短信字数不变的前提下，把链接缩短，其他部分的文字描述就能增加，这样似乎更能达到该短信的实际目的（比如，营销）。短链构成短链的组成通常包含两个部分：域名 + 随机码https://www.ownsyou.com/s2TYdWd短链的域名最好和其他业务域名分开，而且要尽量简短，可以不具备业务含义（比如：xyz.com），因为短链大部分是用于营销，可能会被三方平台屏蔽。(非营销无需考虑这个问题)短链的随机码需要全局唯一，建议10位以下。短链跳转原理首先，我们先看一个短链跳转的简单例子，如下代码，定义了一个 302重定向的代码示例：import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.servlet.view.RedirectView; @Controller publicclass RedirectController { @GetMapping("/{shortCode}") public RedirectView redirect(@PathVariable String shortCode) { String destUrl = "https://yuanjava.com"; // destUrl = getDestUrlByShortCode(shortCode); //真实的业务逻辑 returnnew RedirectView(destUrl); }接着，在浏览器访问短链”http://127.0.0.1:8080/s2TYdWd” 后，请求会被重定向到 https://yuanjava.com 从ws抓包可以看到 302状态码并且请求被 Location到另外一个 URL，整个交互流程图如下：用户通过短链访问到短链系统，然后通过302重定向到目标系统最后，总结下短链跳转的核心思想：生成随机码，将随机码和目标 URL（长链）的映射关系存入数据库；用域名+随机码生成短链，并推送给目标用户；当用户点击短链后，请求会先到达短链系统，短链系统根据随机码查找出对应的目标 URL，接着将请求 302重定向到目标 URL（长链）；关于重定向有 301 和 302两种，如何选择？302，代表临时重定向：每次请求短链，请求都会先到达短链系统，然后重定向到目标 URL（长链），这样，方便短链系统做一些统计点击数等操作；通常采用 302301，代表永久重定向：第一次请求拿到目标长链接后，下次再次请求短链，请求不会到达短链系统，而是直接跳转到浏览器缓存的目标 URL（长链），短链系统只能统计到第一次访问的数据；一般不采用 301。短链生成从短链组成章节可知：短链=域名+随机码，因此，如何生成短链的问题变成了如何生成一个全局唯一的随机码，通常会有 3种做法：Base62Base62 表示法是一种基数为62的数制系统，包含26个英文大写字母（A-Z），26个英文小写字母（a-z）和10个数字（0-9）。这样，共有62个字符可以用来表示数值。如下代码：import java.security.SecureRandom; publicclass RandomCodeGenerator { privatestaticfinal String CHAR_62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; privatestaticfinal SecureRandom random = new SecureRandom(); public static String generateRandomCode(int length) { StringBuilder sb = new StringBuilder(length); for (int i = 0; i < length; i++) { int rndCharAt = random.nextInt(CHAR_62.length()); char rndChar = CHAR_62.charAt(rndCharAt); sb.append(rndChar); } return sb.toString(); }对于 Base62算法，如果是生成 6位随机数有 62^6 - 1 = 56800235583（568亿多），如果是生成 7位随机数有 62^7 - 1 = 3521614606208（3.5万亿多），足够使用。Hash算法Hash算法是我们最容易想到的办法，比如 MD5, SHA-1, SHA-256, MurmurHash, 但是这种算法生成的 Hash值还是比较长，常用的做法是把这个 Hash值进行 62/64进行压缩。如下代码，通过 Google的 MurmurHash算法把长链 Hash成一个 32位的 10进制正数，然后再转换成62进制（压缩），这样就可以得到一个 6位随机数，import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; import java.nio.charset.StandardCharsets; publicclass MurmurHashToBase62 { privatestaticfinal String BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; public static String toBase62(int value) { StringBuilder sb = new StringBuilder(); while (value > 0) { sb.insert(0, BASE62.charAt(value % 62)); value /= 62; } return sb.toString(); } public static void main(String[] args) { // 长链 String input = "https://yuanjava.cnposts/short-link-system/design?code=xsd&page=1"; // 长链利用 MurmurHash算法生成 32位 10进制数 HashFunction hashFunction = Hashing.murmur3_32(); int hash = hashFunction.hashString(input, StandardCharsets.UTF_8).asInt(); if (hash < 0) { hash = hash & 0x7fffffff; // Convert to positive by dropping the sign bit } // 将 32位 10进制数 转换成 62进制 String base62Hash = toBase62(hash); System.out.println("base62Hash:" + base62Hash); } }全局唯一ID很多大中型公司都会有自己全局唯一 ID 的生成服务器，可以使用这些服务器生成的 ID来保证全局唯一，也可以使用雪花算法生成全局唯一的ID，再经过 62/64进制压缩。冲突解决对于上述 3种方法的前 2种：Base62 或者 Hash算法，因为本质上都是哈希函数，所以，不可避免地会产生哈希冲突，尽管冲突的概率已经很低很低了，so，万一冲突了，该如何解决呢？要解决冲突，首先需要检测 Hash冲突，通常来说有 2种检测方法。数据库锁这里以 MySQL数据库为例，表结构如下：CREATE TABLE`short_url_map` ( `id`int(11) unsignedNOTNULL AUTO_INCREMENT, `long_url`varchar(160) DEFAULTNULLCOMMENT'长链', `short_url`varchar(10) DEFAULTNULLCOMMENT'短链', `gmt_create`int(11) DEFAULTNULLCOMMENT'创建时间', PRIMARY KEY (`id`), UNIQUEINDEX'short_url' ('short_url') ) ENGINE=InnoDBDEFAULTCHARSET=utf8;首先创建一张长链和短链的关系映射表，然后通过给 short_url字段添加唯一锁，这样，当数据插入时，如果存在 Hash冲突（short_url值相等），数据库就会抛错，插入失败，因此，可以在业务代码里捕获对应的错误，这样就能检测出冲突。也可以先用 short_url去查询，如果能查到数据，说明 short_url存在 Hash冲突了。对于这种通过查询数据库或者依赖于数据库唯一锁的机制，因为都涉及DB操作，对数据库是一个额外的开销，如果流量比较大的话，需要保证数据库的性能。布隆过滤器在 DB操作的上游增加一个布隆过滤器，在长链生成短链后， 先用短链在布隆过滤器中进行查找，如果存在就代表冲突了，如果不存在，说明 DB里不存在此短链，可以插入。对于布隆过滤器的选择，单机可以采用 Google的布隆过滤器，分布式可以使用 RedisBloom。检测出了冲突，需要如何解决冲突？再 Hash，可以在长链后面拼接一个 UUID之类的随机字符串，然后再次进行 Hash，用得出的新值再进行上述检测，这样 Hash冲突的概率又大大大降低了。高并发场景的架构在流量不大的情况，上述方法怎么折腾似乎都合理，但是，为了架构的健壮性，很多时候需要考虑高并发，大流量的场景，因此架构需要支持水平扩展，比如：采用微服务功能模块分离，比如，短链生成服务和长链查询服务分离功能模块需要支持水平扩容，比如：短链生成服务和长链查询服务能支持动态扩容缓解数据库压力，比如，分区，分库分表，主从，读写分离等机制服务的限流，自保机制完善的监控和预警机制
后端性能优化MySQL主要针对索引，对于MySQL优化的博文已经很多了，可以参阅，这里主要讲关于索引的几个点。索引：MySQL的所有C端查询我们要尽量保证必须用到索引，尤其是那种数据量增长快的表。所以不要在索引字段进行函数运算，不要使用取反等等。联合索引：联合索引使用可能是最多的了，sql没写对可能就导致了全表扫描，记住最左匹配原则，将区分度高的字段放在前面。分页查询：C端经常展示一些列表，比如文章列表，作品列表等，基本都用到了分页查询，这里需要注意的是order by fieldName。要保证fieldName在索引中，不然就可能会出现filesort了，这是个外排序，在数据量大的时候是很致命的。在我们这个项目中，我没说的时候，所有的分页查询都有这个问题。养成个好习惯：创建表的时候就设计好索引，不要到最后出事了再去加，那会数据量很大再加就迟了。分库分表一般预估年增量记录在500W以内都不需要分，我们之前是1000W以内就不分。如果只是临时做活动使用的就更不需要分了，除非活动期间量真的达到了几亿。简单介绍下分的原则数据量大就分表，否则单表的查询性能肯定是下降很多并发高就分库，因为单库的资源就那么多，支持的连接数不会太高。大多数情况下分库分表是一起的，因为量大和并发高经常是同时出现。一开始分就给足资源，二次分表成本太大，在快手一般上来就是10个库和1000张表。需要特别注意的是shardKey，围绕用户展开的就用userId来做shardKey，这在绝大多数情况下没问题的。如何分，见仁见智，可以使用shardingjdbc，优点很明显是减少开发成本。也可以自己手动分，优点也很明显，便于定制化和制定规则。如果你犹豫不决就shardingjdbc吧，尤其是小团队的时候。注意事项1、减少操作数据库次数比如我要批量更新用户的奖励，有人会写这样的代码for (UserReward r : rewardList) { rewardDao.update(r); }如果rewardList的size=10,那会操作数据库10次，也就是10次IO，而明显我们可以一次批量更新解决：rewardDao.batchUpdate(rewardList);虽然看着是那么理所当然，我们的项目甚至不少地方有下面类似的代码User user = userService.getById(userId); // doSomething userService.updateById(user); // doSomething2 userService.updateById(user); // doSomething3 userService.updateById(user);明明可以一次update就解决的为什么要update多次？引申一下，有的时候甚至都不需要操作数据库，这种情况很多时候不容易发现。举个例子，之前活动的时候在活动页面会展示任务信息和用户获得的奖励。// 查询用户任务 UserTask userTask = userTaskService.getById(id, userId); // 构建外显响应 UserTaskResp userTaskVo = buildVo(userTask); // 从结算表查询结算信息，从而得知获得的奖励 UserTaskSettlement s = userTaskSettlementService.getByTaskId(userTaskId, userId); // 填充奖励信息 fillReward(userTaskVo, s);活动页的流量是很大的，平白无故增大了结算表的流量。因为大部分用户是完不成任务的，所以根本没有获得奖励，所以可以判断用户是否完成了任务，只有完成了任务才去查询结算表即可。使用迭代器查询如果我们要查询的数据量很多，比如瓜分活动需要查出来所有参与并完成活动的用户，假设就一张表，有2000W数据，满足要求的用户可能有百万。如何做？直接一个批量查询的sql？肯定是不行的会出现包溢出异常一次查询大量数据到内存，会占用大量内存甚至full gc。正确的做法是分批查询，比如每次查询200条，然后进行处理。当然，不介意写while这种循环，一来不优雅，二来容易写出死循环的代码，最后也没法复用。可以参考下面的迭代器写法import com.google.common.collect.AbstractIterator; import org.apache.commons.collections.CollectionUtils; import java.util.Iterator; import java.util.List; import java.util.function.BiFunction; import java.util.function.Function; /** * @Description 大批量数据迭代器 主要是针对表中数据过多循环查询问题 * List<User> list = Lists.newArrayList(new User(1,"1"), new User(2,"1"), new User(3,"1"), * new User(4,"1"),new User(5,"1"),new User(6,"1"),new User(7,"1")); * SimpleSingeFieldIterable<Integer, User> iterable = new SimpleSingeFieldIterable<>(0, 2, * (position, count) -> list.stream().sorted(Comparator.comparing(User::getId)) * .filter(x -> x.getId() > position).limit(count).collect(Collectors.toList()), User::getId); * for (List<User> list1 : iterable) { * System.out.println(ObjectJsonMapper.toJSON(list1)); * } */ public class SimpleSingeFieldIterable<I extends Comparable, R> implements Iterable<List<R>> { private final BiFunction<I, Integer, List<R>> searchDAO; private I searchPosition; private final Integer count; private final Function<R, I> model2IdFunction; public SimpleSingeFieldIterable(I searchPosition, Integer count, BiFunction<I, Integer, List<R>> searchDAO, Function<R, I> model2IdFunction) { this.searchPosition = searchPosition; this.count = count; this.searchDAO = searchDAO; this.model2IdFunction = model2IdFunction; } @Override public Iterator<List<R>> iterator() { return new SingeFieldIterator(); } class SingeFieldIterator extends AbstractIterator<List<R>> { private boolean needContinue = true; @Override protected List<R> computeNext() { if (!needContinue) { return endOfData(); } List<R> result = searchDAO.apply(searchPosition, count); if (CollectionUtils.isEmpty(result)) { needContinue = false; return endOfData(); } if (result.size() < count) { needContinue = false; } searchPosition = model2IdFunction.apply(result.get(result.size() - 1)); return CollectionUtils.isNotEmpty(result) ? result : endOfData(); } } }使用// 每次查询出来200条DocLibrary SimpleSingeFieldIterable<Long, DocLibrary> docIterable = new SimpleSingeFieldIterable<>(0L, 200, (minId, count) -> baseMapper.getAllList(minId, count), DocLibrary::getId); AtomicInteger counter = new AtomicInteger(0); docIterable.forEach(list -> { LOGGER.info("start batchUpdate! count : {}", counter.get()); List<DocLibrary> effectiveList = list.stream() .filter(x -> StringUtils.isEmpty(x.getFileType())).collect(Collectors.toList()); for (DocLibrary doc : effectiveList) { String fileName = doc.getFileName(); String suffix = StringUtils.substringAfterLast(fileName, "."); doc.setFileType(suffix); } baseMapper.batchUpdate(list); LOGGER.info("start batchUpdate! count : {}", counter.addAndGet(list.size())); }); @Mapper public interface DocLibraryMapper extends BaseMapper<DocLibrary> { void batchUpdate(List<DocLibrary> list); @Select("select * from doc_library where id > #{minId} order by id asc limit #{size}") List<DocLibrary> getAllList(@Param("minId") long minId, @Param("size") int size); }做好SQL监控对于大型C端项目，数据量可能很大，一定要做好SQL监控，也就是sql调用量，qps，耗时等。毕竟人是不可靠的，即使你能保证你没问题，但你保证不了别人。缓存关于缓存Redis，这里只做一些简单罗列。1、使用Redis加快访问：大厂里面肯定没问题，小厂里面很多项目几乎看不使用redis，全是直接搂数据库。注意下缓存一致性即可，大部分场景使用Cache Aside策略即可。2、Redis大key问题：大的String，比如1M。集合数据太多，比如5000个。这都是大key问题，redis处理仍然是单线程，大key会拖慢整个redis，并且影响带宽。常用解决方案就是拆分成多个小key，放到多个节点即可。3、redis热key问题：如果某个redis的key访问量很高，那么这个key就是热key，比如我们可以认为该key的qps达到5000（看业务）那么就是热key，解决方案：使用本地缓存：可以是guava的cache也可以是快手开源的全局本地缓存。使用memcached：memcached扛热点能力可比redis好多了。冗余写，随机读：也就是写的时候写多个副本到不同的redis节点，读的时候选其中一个节点读。4、关于缓存击穿，一般发生在缓存过期后大量请求落到数据库，这个缓存不一定是说redis，也可以是本地缓存如果是本地缓存，一般就是guava的cache，使用 load miss 方法即可，也就是cache.get(key, Callable)，内部是会加锁的。如果是redis，那么最容易想到的就是分布式锁，当然，也可以写个cacheSetter服务，思路是保证相同的key落到同一台服务器，然后使用jvm级别的锁去加锁处理即可；import com.google.common.collect.Lists; import org.apache.commons.collections4.CollectionUtils; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CountDownLatch; /** * @Description 分布式加载缓存的rpc服务，如果部署了多台机器那么调用端最好使用id做一致性hash保证相同id的请求打到同一台机器。 **/ public abstract class AbstractCacheSetterService implements CacheSetterService { private final ConcurrentMap<String, CountDownLatch> loadCache = new ConcurrentHashMap<>(); private final Object lock = new Object(); @Override public void load(Collection<String> needLoadIds) { if (CollectionUtils.isEmpty(needLoadIds)) { return; } CountDownLatch latch; Collection<CountDownLatch> loadingLatchList; synchronized (lock) { loadingLatchList = excludeLoadingIds(needLoadIds); needLoadIds = Collections.unmodifiableCollection(needLoadIds); latch = saveLatch(needLoadIds); } System.out.println("needLoadIds:" + needLoadIds); try { if (CollectionUtils.isNotEmpty(needLoadIds)) { loadCache(needLoadIds); } } finally { release(needLoadIds, latch); block(loadingLatchList); } } /** * 加锁 * @param loadingLatchList 需要加锁的id对应的CountDownLatch */ protected void block(Collection<CountDownLatch> loadingLatchList) { if (CollectionUtils.isEmpty(loadingLatchList)) { return; } System.out.println("block:" + loadingLatchList); loadingLatchList.forEach(l -> { try { l.await(); } catch (InterruptedException e) { e.printStackTrace(); } }); } /** * 释放锁 * @param needLoadIds 需要释放锁的id集合 * @param latch 通过该CountDownLatch来释放锁 */ private void release(Collection<String> needLoadIds, CountDownLatch latch) { if (CollectionUtils.isEmpty(needLoadIds)) { return; } synchronized (lock) { needLoadIds.forEach(id -> loadCache.remove(id)); } if (latch != null) { latch.countDown(); } } /** * 加载缓存，比如根据id从db查询数据，然后设置到redis中 * @param needLoadIds 加载缓存的id集合 */ protected abstract void loadCache(Collection<String> needLoadIds); /** * 对需要加载缓存的id绑定CountDownLatch，后续相同的id请求来了从map中找到CountDownLatch，并且await，直到该线程加载完了缓存 * @param needLoadIds 能够正在去加载缓存的id集合 * @return 公用的CountDownLatch */ protected CountDownLatch saveLatch(Collection<String> needLoadIds) { if (CollectionUtils.isEmpty(needLoadIds)) { return null; } CountDownLatch latch = new CountDownLatch(1); needLoadIds.forEach(loadId -> loadCache.put(loadId, latch)); System.out.println("loadCache:" + loadCache); return latch; } /** * 哪些id正在加载数据，此时持有相同id的线程需要等待 * @param ids 需要加载缓存的id集合 * @return 正在加载的id所对应的CountDownLatch集合 */ private Collection<CountDownLatch> excludeLoadingIds(Collection<String> ids) { List<CountDownLatch> loadingLatchList = Lists.newArrayList(); Iterator<String> iterator = ids.iterator(); while (iterator.hasNext()) { String id = iterator.next(); CountDownLatch latch = loadCache.get(id); if (latch != null) { loadingLatchList.add(latch); iterator.remove(); } } System.out.println("loadingLatchList:" + loadingLatchList); return loadingLatchList; } }如果能接受一定的脏数据，那么甚至可以设置缓存永不过期，但是要设置个逻辑过期字段,如果过期了异步加载即可。String get(String key) { V v = redis.get(key); if (v.getLogicTime() < System.currentTimeMills()) { String mutexkey = buildMutexKey(key); if (redis.set(mutexKey, "1", "ex", 100, "nx")) { executor.execute(() -> {//重建缓存}) } } return v.getValue(); }集群隔离，为了保证业务直接不互相影响，最好做下集群隔离，比如任务系统使用task集群，奖励系统使用reward集群。多级缓存：如果并发超高，可以考虑使用多级缓存，比如对于活动系统，在后台创建活动然后下发给用户，活动相关的基础信息是不变的，完全可以使用本地缓存+redis+mysql，设置可以加一层全局本地缓存。内存布隆过滤器实战案例布隆过滤可用来高效判断一个元素是否存在于一个集合（小概率存在假阳性，需要二次判断），因此可以用来作为流量漏斗。实际场景例如某个功能只对少部分用户开放，功能入口的查询流量很高，需要高性能的判断用户是否拥有该功能权限，但权限查询接口扛不住这么高的流量，此时使用布隆过滤则可以过滤掉所有无权限用户的请求，仅处理少部分可能有权限的用户查询即可。由于源数据查询服务性能有限，增加了一层对外查询服务，在该服务启动时增加钩子，需要从源数据查询服务拉取到全量的源数据构建内存中的BloomFilter，构建完成后再开始对外提供服务。若源数据会更新，则运行期间需要定时reload源数据以重新构建BloomFilter。对外查询服务通过BloomFilter来拦截掉大部分明确不存在于源数据集中的查询请求，从而使得源数据查询服务得到保护。消息队列与流量聚合MQMQ(消息队列)主要是来做异步削峰的，比如直播间点赞，收礼等，如果服务端收到请求后直接同步操作数据库那么晚高峰的时候对数据库就是灾难的，所以必须使用消息队列来处理。场景介绍这里就举两个常见的例子电商秒杀：每次用户秒杀的结果虽然在页面展示了，实际上一般是把请求丢到了消息队列了，后续慢慢消费处理（db中库存的改动等）；直播间收礼：在一些大V开直播的时候，直播收礼的流量10W/s很正常，服务端需要对礼物进行归类和统计等处理，这显然也可以使用mq去异步处理；注意事项我们需要关注的是消费速度和性能之间的平衡，如果消费过快，那么下游扛不住，如果消费过慢又会导致消息堆积，消费到冷数据并影响业务。解决思路拿RocketMQ来举例，消费者从broker拉取的消息是极快的，一般这里不会成为性能瓶颈，往往成为性能瓶颈的是业务的IO操作，所以发现消息堆积了的话，先从IO那块想办法解决。比如是不是流量增大了导致消费跟不上，这时候可以考虑在不影响性能的情况下调整消费线程数或者消费者扩容。或者是不是一些IO接口出问题了。流量聚合流量聚合简单来说就是把多次的请求整合为一个请求处理，显然只有在业务对单次的请求不敏感时并且能接受一定延迟时才能使用，比如直播间点赞，主播对单次点赞根本不敏感，直播间展示的赞数也不是实时的，完全可以对多次点赞进行聚合，最后再进行一些列判断再累计起来存放到db或缓存中，直播间从db或缓存中进行拉取。比如我要统计每天消耗大模型的token数，当每次有相关接口调用的时候就将消耗的token数发到消息队列，然后在消费者侧进行流量聚合。再比如，我们使用binlog监听工具用户任务表（散了1000张表），每次产生用户任务信息我们就往ES写(超高流量)，然后在后台我们去ES搜索，所以我们也可以每次监听到消息后进行聚合，最后批量写到ES。那么这个聚合的工具需要什么功能呢？简单来说主要就三点提供个能存放数据的容器当达到指定数量时输出容器中的数据当达到指定时候时输出容器中的数据刚好快手提供了这么个工具-BufferTrigger，这个工具在快手内部大量使用，尤其是主站直播，下面将简单介绍下这个简单易用的工具。public class BufferTrigger<E> { private static final Logger logger = getLogger(BufferTrigger.class); private final BlockingQueue<E> queue; private final int batchSize; private final long lingerMs; private final ThrowableConsumer<List<E>, Exception> consumer; private final ScheduledExecutorService scheduledExecutorService; private final ReentrantLock lock = new ReentrantLock(); private final AtomicBoolean running = new AtomicBoolean(); private BufferTrigger(long lingerMs, int batchSize, int bufferSize, ThrowableConsumer<List<E>, Exception> consumer) { this.lingerMs = lingerMs; this.batchSize = batchSize; this.queue = new LinkedBlockingQueue<>(max(bufferSize, batchSize)); this.consumer = consumer; this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); // 执行一次调度 this.scheduledExecutorService.schedule(new BatchConsumerRunnable(), this.lingerMs, MILLISECONDS); } public void enqueue(E element) { try { queue.put(element); tryTrigBatchConsume(); } catch (InterruptedException e) { currentThread().interrupt(); } } private void tryTrigBatchConsume() { if (queue.size() >= batchSize) { if (lock.tryLock()) { try { if (queue.size() >= batchSize) { if (!running.get()) { // prevent repeat enqueue this.scheduledExecutorService.execute(this::doBatchConsumer); running.set(true); } } } finally { lock.unlock(); } } } } public void manuallyDoTrigger() { doBatchConsumer(); } private void doBatchConsumer() { lock.lock(); try { running.set(true); while (!queue.isEmpty()) { List<E> toConsumeData = new ArrayList<>(min(batchSize, queue.size())); queue.drainTo(toConsumeData, batchSize); if (!toConsumeData.isEmpty()) { doConsume(toConsumeData); } } } finally { running.set(false); lock.unlock(); } } private void doConsume(List<E> toConsumeData) { try { consumer.accept(toConsumeData); } catch (Throwable e) { logger.error("doConsume failed", e); } } private class BatchConsumerRunnable implements Runnable { @Override public void run() { try { doBatchConsumer(); } finally { scheduledExecutorService.schedule(this, lingerMs, MILLISECONDS); } } } public long getPendingChanges() { return queue.size(); } public static <E> BufferTriggerBuilder<E> newBuilder() { return new BufferTriggerBuilder<>(); } public static class BufferTriggerBuilder<E> { private int batchSize; private int bufferSize; private Duration duration; private ThrowableConsumer<List<E>, Exception> consumer; public BufferTriggerBuilder<E> batchSize(int batchSize) { this.batchSize = batchSize; return this; } public BufferTriggerBuilder<E> bufferSize(int bufferSize) { this.bufferSize = bufferSize; return this; } public BufferTriggerBuilder<E> duration(Duration duration) { this.duration = duration; return this; } public BufferTriggerBuilder<E> consumer(ThrowableConsumer<List<E>, Exception> consumer) { this.consumer = consumer; return this; } public BufferTrigger<E> build() { Preconditions.checkArgument(batchSize > 0, "batchSize 必须大于0"); Preconditions.checkArgument(bufferSize > 0, "bufferSize 必须大于0"); Preconditions.checkNotNull(duration, "duration未设置"); Preconditions.checkNotNull(consumer, "消费函数未设置"); return new BufferTrigger<>(duration.toMillis(), batchSize, bufferSize, consumer); } } }测试public class BufferTriggerDemo { private static AtomicInteger counter = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { BufferTrigger<Long> trigger = BufferTrigger.<Long>newBuilder() .batchSize(10) .bufferSize(100) .duration(Duration.ofSeconds(5)) .consumer(BufferTriggerDemo::consume) .build(); for (long i = 0; i < 10000; i ++) { trigger.enqueue(i); TimeUnit.MILLISECONDS.sleep(100); } } private static void consume(List<Long> list) { System.out.printf("次数 %d, result : %s \n", counter.incrementAndGet(), list); } }full gc陷阱需要注意的是BufferTrigger是单线程消费的，这个是一个比较大的陷阱，做活动的时候是踩了坑的，尤其是在消费操作中涉及到大量io操作的场景，因为在流量很高的时候可能会出现消费速度跟不上生产速度，这很容易导致full gc问题。所以如果有必要的话需要使用线程池来提升消费速度。消息队列和BufferTrigger的组合方式有没有考虑过消息队列的消费速度过慢，如何在不影响下游性能的情况下提升消费速？比如直播点赞，在mq每次收到一条点赞消息的时候是不是就可以使用BufferTrigger来进行聚合？然后每分钟消费一次，在流量剧增的时候是不是能十倍以上的提速？线程池 线程池作为jdk的一个重要组件，同时也是性能优化的常客，不得不谈。1、如果异步，尤其现在大模型的场景，基本上是必不可少。比如你要请求的两个接口没有关联性，可以考虑使用线程池去并发请求。2、对于非核心逻辑我们也可以使用线程池处理，比如用户完成任务后要给他发个触达（短信，私信等），可以使用线程池去异步处理。3、当然，存在明显的问题就是服务重启就没了，所以对性能要求高的服务还是得使用MQ。4、服务端大多是IO型操作，所以能够将线程数调大一点，起始值我们可以设置为核数*2。要设置一个合理的值，我们可以加上监控，尤其要监控活跃线程数和堆积数，从而来调整一个合适的值，并且帮助快速发现问题。之前出现一个问题可以作为借鉴，线上大模型相关接口经常卡住，查了半天猜测是线程池里面的任务处理耗时太长，甚至卡住了，导致新的请求进了队列并一直得不到消费，为了验证这个问题，加上了线程池监控，发现确实和猜测的吻合。1、如果你使用rpc，比如thirft，那么一定要使用自定义的线程池，并且监控，之前在小爱的时候就出现rpc接口在早高峰耗时上涨，究其原因是因为流量上涨，导致线程池处理不过来，跟上面说的问题很类似。2、此外建议给线程池命名，这样在发生问题使用jstack来dump线程堆栈的时候好分析问题。[root@turbodesk-api-canary-c9b685f9-ctclm home]# jstack 1 > thread.log [root@turbodesk-api-canary-c9b685f9-ctclm home]# ll total 752 -rw-r--r-- 1 root root 769667 Jan 17 17:47 thread.log [root@turbodesk-api-canary-c9b685f9-ctclm home]# grep java.lang.Thread.State thread.log | awk '{print $2$3$4$5}' | sort | uniq -c 69 RUNNABLE 3 TIMED_WAITING(onobjectmonitor) 8 TIMED_WAITING(parking) 6 TIMED_WAITING(sleeping) 2 WAITING(onobjectmonitor) 706 WAITING(parking)可以使用[Smart Java thread dump analyzer - thread dump analysis in seconds](https://fastthread.io/)来帮忙分析如果你的接口是间歇性的有大量请求，可以将核心线程数调小一些，避免白创建太多线程处于waitting状态。系统能创建的线程数是有限的，如何计算我就不谈了，比如我们的系统最多能创建1W个线程，之前有个同事的接口，在每次请求的时候创建了只有一个线程的线程池，导致服务经常重启。其他技巧请求丢弃在很多业务场景下，丢弃部分请求完全不影响业务，给个合理的提示的话用户根本无法感知，这其中最常见的就是秒杀、抢红包、发弹幕这类业务，请求量很大，但只有少部分请求能拿到钱，大部分请求直接丢弃都是没问题的，到时候告诉用户没抢到或者抢没了或者弹幕发生成功即可。预处理这类场景在抢红包的时候经常用到，可能很多人都知道很多app抢红包其实并不是在用户每次去抢的时候再去计算金额并落库的，一般是在红包发出去的时候就计算好了，比如1W块钱，100个人，那么就会随机生成100个数额推入到缓存的队列，用户抢的时候从队列直接pop即可。当然，可以通过消息队列异步落库，也就是同步写缓存，异步写db。又或者是在用户完成活动的时候得到一个资格，然后在指定的时间点去瓜分1W块钱，其实也是在这时间点之前就把能用的金额和获得资格的人拿出来，计算出每个人的金额再存储到缓存和db，最后用户虽然说是去抢，其实就是从缓存中把已经给他存好的钱拿出来了而已。
GraalVM替代JVM的方式方法和不足GraalVM 基本上可以看成是Oracle大力发展的新一代JVM，但是目前的使用情况还不大乐观，而且逐渐发现大家都有往GO语言转的趋势，本文具体分析一下替代方案和不足之处，为什么大家还是更希望使用GO。GraalVM与JVM关系JVM 全称 Java 虚拟机，我们都知道，Java 程序是运行在虚拟机上的，虚拟机提供 Java 运行时，支持解释执行和部分的（JIT）即时编译器，并且负责分配和管理 Java 运行所需的内存，我们所说的各种垃圾收集器都工作在 JVM 中。比如 Oracle JDK、OpenJDK ，默认的 JVM 是 HotSpot 虚拟机。GraalVM 可以完全取代上面提到的那几种虚拟机，比如 HotSpot。把你之前运行在 HotSpot 上的代码直接平移到 GraalVM 上，不用做任何的改变，甚至都感知不到，项目可以完美的运行。GraalVM主要想搭建一个Framework，最终目的是实现支持任何一种语言，可以共同跑在 GraalVM 上，不存在跨语言调用的壁垒。GraalVM 和JDK关系Java 虚拟机都是内置在 JDK 中的，比如Orcale JDK、OpenJDK，默认内置的都是 HotSpot 虚拟机。GraalVM 也是一种 JDK，一种高性能的 JDK。完全可以用它替代 OpenJDK、Orcale JDK。GraalVM 如何运行 Java 程序GraalVM - 还包含 Graal （JIT）即时编译器，可以结合 HotSpot 使用GraalVM – 是一种高性能 JDK，旨在加速 Java 应用程序性能，同时消耗更少的资源。GraalVM - 是一种支持多语言混编的虚拟机程序，不仅可以运行 JVM 系列的语言，也可支持其他语言。GraalVM 提供了两种方式来运行 Java 程序。第一种：结合 HotSpot 使用GraalVM 包含 Graal （JIT）即时编译器，自从 JDK 9u 版本之后，Orcale JDK 和 OpenJDK 就集成了 Graal 即时编译器。这样Java 既有解释运行也有即时编译。当程序运行时，解释器首先发挥作用，代码可以直接执行。随着时间推移，即时编译器逐渐发挥作用，把越来越多的代码编译优化成本地代码，来获取更高的执行效率。即时编译器可以选择性地编译热点代码，省去了很多编译时间，也节省很多的空间。比如多次执行的方法或者循环、递归等。JDK 默认使用的是 C2 即时编译器，C2是用C++编写的。而使用下面的参数可以用 Graal 替换 C2。-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompilerGraal 编译器是用 Java 实现的，用 Java 实现自己的编译器。Graal 基于一些假设的条件，采取更加激进的方式进行优化。采用 Graal 编译器之后，对性能有会有一定的提升。但是Graal，VM不支持JDK8第二种：AOT 编译本地可执行程序AOT 提前编译，是相对于即时编译而言的。AOT在运行过程中耗费 CPU 资源来进行即时编译，而程序也能够在启动的瞬间就达到理想的性能。例如 C 和 C++语言采用的是AOT静态编译，直接将代码转换成机器码执行。而 Java 一直采用的是解释 + 即时编译技术，大多数情况下 Java 即时编译的性能并不比静态编译差，但是还是一直朝着 AOT 编译的方向努力。但是 Java 对于 AOT 来说有一些难点，比如类的动态加载和反射调用。GraalVM 的 AOT 编译实际上是借助了 SubstrateVM 编译框架，可以将 SubstrateVM 理解为一个内嵌精简版的 JVM，包含异常处理，同步，线程管理，内存管理（垃圾回收）和 JNI 等组件。SubstrateVM 的启动时间非常短，内存开销非常少。除了运行时占用的内存少之外，用这种方式最终生成的可执行文件也非常小。这对于云端部署非常友好。目前很多场景下都使用 Docker容器的方式部署，打一个 Java 程序的镜像包要包含完整的 JVM 环境和编译好的 Jar 包。而AOT 方式可以最大限度的缩小 Docker 镜像的体积。缺点对于反射这种纯粹在运行时才能确定的部分，不可能完全通过优化编译器解决，只能通过增加配置的方式解决。多语言支持GraalVM 中的另一个核心组件 Truffle是一个用 Java 写就的语言实现框架。基于 Truffle 的语言实现仅需用 Java 实现词法分析、语法分析以及针对语法分析所生成的抽象语法树（Abstract Syntax Tree，AST）的解释执行器，便可以享用由 Truffle 提供的各项运行时优化。就一个完整的 Truffle 语言实现而言，由于实现本身以及其所依赖的 Truffle 框架部分都是用 Java 实现的，因此它可以运行在任何 Java 虚拟机之上。当然，如果 Truffle 运行在附带了 Graal 编译器的 Java 虚拟机之上，那么它将调用 Graal 编译器所提供的API，主动触发对 Truffle 语言的即时编译，将对 AST 的解释执行转换为执行即时编译后的机器码。安装并使用GraalVm社区版是基于OpenJDK 11.0.17, 17.0.5, 19.0.1，如果你想用免费的，只能将程序升级到 JDK 11 以上了。对于Linux来说，下载下来的压缩包，直接解压，然后配置环境变量。把解压目录配置到环境变量的 JAVA_HOME就可以了。解压好其实就相当于安装完毕了，查看一下版本。进入到解压目录下的bin目录中，运行 java -version。运行结果和平常使用JDK也没什么差别。代码运行这里不讲常用的测试环境代码运行方式，无非是做好配置，然后运行。这里主要讲一下关于使用AOT编译为机器码，然后以可执行文件的形式来做：native-image 可以命令行的形式执行，也可以在配合 Maven 执行，这里主要讲用Maven搭配的方式。1、安装native-image 工具包native-image 是用来进行 AOT 编译打包的工具，先把这个装上，才能进行后面的步骤。安装好 GraalVM 后，在 bin目录下有一个叫做 gu的工具，用这个工具安装，如果将 bin目录添加到环境中，直接下面的命令安装就行了。gu install native-image如果没有将 bin目录加到环境变量中，要进入到 bin目录下，执行下面的命令安装。./gu install native-image这个过程可能比较慢，因为要去 github 上下载东西。2、配置 Maven配置各种版本 <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>${java.specification.version} </maven.compiler.source> <maven.compiler.target>${java.specification.version}</maven.compiler.target> <native.maven.plugin.version>0.9.12</native.maven.plugin.version> <imageName>graalvm-demo-image</imageName> <mainClass>org.graalvm.HelloWorld</mainClass> </properties>native.maven.plugin.version是要用到的编译为可执行程序的 Maven 插件版本。imageName是生成的可执行程序的名称。mainClass是入口类全名称。配置 build 插件 <build> <plugins> <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>3.0.0</version> <executions> <execution> <id>java-agent</id> <goals> <goal>exec</goal> </goals> <configuration> <executable>java</executable> <workingDirectory>${project.build.directory}</workingDirectory> <arguments> <argument>-classpath</argument> <classpath/> <argument>${mainClass}</argument> </arguments> </configuration> </execution> <execution> <id>native</id> <goals> <goal>exec</goal> </goals> <configuration> <executable>${project.build.directory}/${imageName}</executable> <workingDirectory>${project.build.directory}</workingDirectory> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> ${maven.compiler.source} <target>${maven.compiler.source}</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.2.2</version> <configuration> <archive> <manifest> <addClasspath>true</addClasspath> <mainClass>${mainClass}</mainClass> </manifest> </archive> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <archive> <manifest> <addClasspath>true</addClasspath> <mainClass>${mainClass}</mainClass> </manifest> </archive> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> </configuration> </plugin> </plugins> </build>配置 profiles <profiles> <profile> <id>native</id> <build> <plugins> <plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <version>${native.maven.plugin.version}</version> <extensions>true</extensions> <executions> <execution> <id>build-native</id> <goals> <goal>build</goal> </goals> <phase>package</phase> </execution> <execution> <id>test-native</id> <goals> <goal>test</goal> </goals> <phase>test</phase> </execution> </executions> <configuration> <fallback>false</fallback> <buildArgs> <arg>-H:DashboardDump=fortune -H:+DashboardAll</arg> </buildArgs> <agent> <enabled>true</enabled> <options> experimental-class-loader-support </options> </agent> </configuration> </plugin> </plugins> </build> </profile> </profiles>3、使用 maven 编译，打包成本地可执行程序。执行 Maven 命令mvn clean package或者mvn -Pnative -Dagent package 编译打包的过程比较慢，因为要直接编译成机器码，所以比一般的编译过程要慢一些。看到下面的输入日志，说明打包成功了。4、运行可执行程序包，打开 target 目录，已经看到了graalvm-demo-image可执行程序包了。然后就可以运行它了，进入到目录下，执行下面的命令运行，可以看到正常输出了。注意了，这时候已经是没有用到本地 JVM 了。缺点不能直接编译，需要先用 -agentlib 模式运行程序生成编译需要的配置文件，这个过程需要把程序的大部分功能都运行一遍，以免漏掉反射代码导致编译后的程序运行异常。这一步要耗费大量时间精力，非常难受。使用别的技术直接编译代码就可以了，而使用 native-image 还要有这样一个前置步骤，非常不方便。编译速度过慢，比直接用javac编译慢好几倍，体验较差。windows上只有 Serial GC（这相当于直接放弃了windows平台），linux上有Serial GC和G1，ZGC之类不支持。（垃圾回收机制根据操作系统平台不同支持不同）编译出来的包比原本的 jvm + jar 加起来还大，导致运维会比较麻烦，网络不畅通或者使用堡垒机会比较难受。native-image 编译出来的程序不支持arthas、jprofiler、visualvm等问题排查和性能分析工具，直接导致java程序一大优势丧失。因为不是jit，峰值性能不如java，对于服务器程序来说也不合适好处就是节省内存、代码不会被破解，但是对于GO来说，这些直接都被取消了，当然GO最麻烦的其实是需要重新学一门语言比较难受。GO作为一门Native语言，编译速度快到可以在开发阶段考虑实时编译热更新，但是使用quarkus和SpringBoot Native修改一行的编译还需要时间。最麻烦的可能是一些第三方库，要支持这些第三方库还需要考虑JDK版本的兼容，但是你知道很多东西其实没有一直更新和维护的。社会环境问题首先刚研发的新技术是需要一个生态和环境的，我不知道国外生态怎么样啊，但是国内生态都是KPI，在这种环境下新技术的出现如果不能很明显提高生产效率，而实际上去提高运维效率和一些不是很重要的成本（对于企业来说，硬盘和内存条成本不高，反而是人力成本更高），所以在国内这个土壤就不太适应，当然不乏一些技术大牛会看好这项技术的发展，还有一些性价比玩家会考虑用最低配的硬件来实现最高的性能，但是对于市场化的东西来说就没有可接受性。或许得等SpringBoot4上线之后，才能解决这个问题。
怀化之旅-2025.5.5今天的路线是玉皇宫——人民公园——市政府——市民广场——回家。起床！今天的早餐时白糖包和香菇干笋包（好吧，这个也放了辣椒哈哈哈）玉皇宫早上来参拜一下玉皇宫，据说这是这边最大的道教宫殿，还蛮有名气，其实这个确实挺好看的，当然师傅们可能就是香火比较少，所以遇到每个人都会问是不是来拜拜的。我碰到了一对情侣也是来参观的，他们不好意思，我就和师傅说了，我们仨都是来参观的，师傅作了作揖，也很愉快地让我们进去了，只是特地要求按照规矩来，不要走中间，左右两侧进入。山大门在修缮，有很多神像都在修：玉灵官、哼哈二将、青龙、白虎、朱雀、玄武、龙王、武陵山神、城隍爷。这里就不给大家放神像的图片了，因为还在修复中，他们估计也不太愿意让人见到这个模样。踏着阶梯上来可以在这里拍到很不错的一个整体景色可以看到对面的小区，也可以看到怀化市的风景。这里你要说百家姓呢，倒也不一定，主要还是怀化这边的姓氏，类似于祠堂一样的感觉吧，不过确实供奉了一些神像例如女娲、炎帝、黄帝，所以也算是比较有意思的宫殿。财神殿的话也是肯定会有的了。如果你去佛教的话一般就是地藏王在右侧，然后左侧珈蓝关公，所以怀化的玉皇宫是比较纯粹的道教了。走，去市政府如果开电动可能更快，但是我不急，在这里和一个老奶奶聊起了天。对方捡到了一张身份证，还问我能不能帮忙送给他，因为是沅陵县的实在太远了，而且我要返程了，我看了下失主年龄不算很大，估计会补办的，就让她别再担心了。老奶奶问我哪里人，我说广东的，她说她有个佛山亲戚清明节带他们一起去佛山玩了。但是由于熟悉这里的环境，觉得还是回来比较舒服。她还问我来这里干嘛，是来出差吗？哈哈哈，我看起来很像来出差的吗？我记得我搭高铁来的时候，隔壁坐的怀化阿姨也问我是不是来出差，可能怀化这座城市还是更偏向做生意吧。离市政府最近的地方其实是妇幼保健院，所以就在这里下车了。老大爷们富有生活气息地在这里唱歌，搞艺术创作。说实话，我觉得古城还不如人民公园，这里跑跑步放松放松，看看山水也是很不错的。整个怀化地政务都在这一圈了，有不同的业务局，例如住建局啊等等。因为想去法院看看，所以得去以前的市民广场，就在这里等公交，当然实际上要去的是“市委”站。法院怀化蛮神奇的，真的有很多广东肠粉，粤菜也不少，有很多早茶，虽然正宗不正宗我没试过，但是总归可能和广东的情谊也蛮深。怀化最近都在宣传这个，其实你在哪里都能看到。茶颜悦色要离开怀化回广州了，离开前再来尝试喝一杯茶颜悦色吧，这次我要喝声声乌龙了。谢谢某人推荐，很好喝。在这里打车回酒店收拾东西，打车正好遇到了拼车的，遇到了个带小孩的母亲。我也不是很急，所以我就跟司机说可以的，没问题。我觉得母亲带小孩还蛮幸苦，司机还一直跟我说现在的年轻人比较自私自我，我觉得吧应该要分情况考虑吧，也希望大家能多给这个社会一些包容的机会。候车厅好多人啊！回到广州了。
怀化之旅-2025.5.4今天带大家去凤凰古镇，说实话不知道为啥要去，反正就是去了。凤凰古镇确实很商业化，很多女生去那边拍照，在高铁上就已经在我旁边化妆。凤凰古镇可能夜景才是最好看的，但是我是白天去的，我个人觉得比前面两个古城要好玩一些，可能是因为有沈从文的一个印象加成，也可能是因为这地方是确实有山山水水的。早餐一大早起床，今天是坐高铁过去凤凰古城！早上起床先去吃二合一，对，也是某人推荐的。因为这家店做外卖，然后离的也不是很远，所以就慢慢走过去了。这家店应该还可以，如果不要辣椒记得提前和老板说，我是没说，因为想感受一下到底有多辣。可以选择不同的臊子，我选了血鸭。面食其实更偏向北方，因为面有韧性而且还带有淀粉的感觉，这种是我在广州接触不到的，老家捞面倒是有这种类型的（要不说潮汕人也很多北方的习性）。说实话，这个比怀化名堂要辣，如果不带水真的是有点撑不住。出发我没想到还有免费充电区，这个比我去过的很多车站真的要好很多了！上车！对了，之前不是说有个陆港？出怀化到凤凰古城的路上就可以看到。出站了，有点羡慕别人。由于去的时间比较早，我打算回来再搭磁悬浮，去直接接驳车。说是接驳车，实际上就是一辆公交车，但是可以开的很快，在郊外和市内飞驰！我选择了奇峰广场，没有选择南华里，大多数人是去南华里的。当然我觉得奇峰广场广场挺好，可以从下游开始看完，然后回到市区。要了个苗饼，其实就是蔬菜做的底子，类似于青稞之类的，里面夹着鸡蛋和一些小料。其实这里有个小插曲，我说了不想要辣的（二合一后遗症太严重），但是阿姨还是加上去了，她说很香，好吧，确实没那么辣哈哈哈！苗寨要收门票，我是没有上去，一方面确实有点害怕苗族的蛊虫（虽然不信，但是虫子还是有点担心）逛古城古城很多山水很好看！臭美一下！忘了凤求凰的典故了，可能也是爱而不得吧，不知道，想到这突然有点小难过。喝杯奶茶吧还蛮好喝的，点了翠兰，招牌可能没什么问题。小哥跟我说要等一会儿，因为店里就他一个人招呼我，但是还要做很多杯。旁边就是非遗馆，可以稍微歇歇脚，也可以看看。这里还有民族英雄郑国鸿。沈从文旁边是沈从文墓，可以过来稍微感受一下文学大家的氛围。沈从文书屋这里有个沈从文书屋，可以买书，也可以借阅，可以感受沉淀，整个书屋的装扮很不错。如果可以作弊，我会想你念你，到最后的荼蘼——《沉醉的青丝》，写到这里突然想起这首歌。继续逛古城感觉夜景应该会很好看吧，这个水上集市。河虾是活蹦乱跳的，虾饼趁热吃。作为潮汕人，怎么能不来天后宫看看呢？哈哈哈，这里面有两个女生，说什么都可以不拜，财神殿一定要拜。年轻人的精神状态如此可见一斑。旁边的阿嫲说，到了这里都要拜，怎么可以这么说。哈哈哈，又是新老观念的冲击。逛着逛着都到南华里来了，看到这个突然想起和某人讨论过提字的人。我没进去，因为估计没啥好看的，门票卖的有点贵。这是军事文化好像，反正我不感兴趣，不过门口可以猜拳赢取门票。说实话后面没啥能看的了，整个古城也就这样，准备跑路去吃午饭，想吃那间亲妈牛肉！去吃午饭啦！跑路去吃饭的路上发现一件事情，首先是南华里其实可以吃饭，然后也可以从这里坐接驳车回去。但是我想去的亲妈牛肉其实在客运站那边，就是有个5km左右的距离，然后呢！！！！！这边其实是打不到车的！顶，整个凤凰古城打车居然要用自己的APP，所有的高德、美团、滴滴全都打不到的。而且不知道是不是因为节假日的原因，公交也完全不上班，于是乎！！！我不得已带上了头盔，选择了年轻人最喜欢的方式——共享电动。好！经过一番波折总算是来到这家店了！点了份小炒黄牛和一份蒜炒油麦菜，一个人完全够吃！广东人吃辣实力一般哈哈哈。旁边就是集散中心，溜了溜了，打算回怀化了，这边也不好玩。找寻凤凰历史文化博物馆本来吃完打算逛完这边历史文化博物馆再回去的，因为担心赶不上车还买了后面的票，没想到博物馆倒闭了。。。。说实话这玩意儿能倒闭我是没想到的，或许政府想着更新一下吧，反正里面全空的，也有可能合并到其他博物馆里面去了。磁悬浮人生中第一次坐磁悬浮列车，主要还很担心赶不上高铁，怕磁悬浮列车很慢，结果一问就十来分钟，那还蛮早的。高铁站返程这会儿回去的人超多，可能都到5.4了，大家伙们已经玩够了想回去了。笑死，全国都已经在推行取消车票领取了，只有凤凰古城还可以领取到车票。不管在哪里，总能看到你！广铁U彩！你到底卷走了多少人的车票钱。晚餐回到怀化了，由于司机特别推荐，晚上去吃了烧烤小龙虾。怀化人可能过的还是蛮压抑的，因为司机师傅说他一辈子都不想买房了，他看起来还蛮年轻的。年轻人都在往外面跑打工，怀化的工资比较低，然后消费又比较高，不过哪个城市都一样吧，这就是快速城市现代化导致的结果。前代人享受的东西，需要后代人不断去还债，所以大家才会这么颓废吧。但是还是希望大家充满对生活的希望，找到也爱到对的人，互相在这个世道下好好的生存下去。世界或许不会变得更好了，但是互相扶持互相坚定，在对方失落的时候给对方勇气，在忙碌了一整天互相拥抱，日子也会变得更有生活气息和意义。
怀化之旅-2025.5.3好像没办法不去古商城，毕竟网红打卡地，虽然去之前已经看到评价一般了，不过终归还是想去尝试看一下。对了去这俩古城很麻烦，就是本地人都是开车。嗯，作为外地人的我选择了大巴，前一晚还在各种做攻略。没想到怀化的交通一般，但是汽运站还是很不错的，建议大家出行可以选坐大巴。大早上来吃小笼包听说这个很出名，所以还是来尝试一下，我自己本人对皮厚皮薄没什么特别的偏爱，不过怀化的小笼包确实偏向北方，因为我在扬州的时候尝试过，面皮筋道而且厚实。不错，还配了海带汤，可能知道水份比较少，怕客人噎着了。怀化人好像真的蛮喜欢做生意，结账都是互相道发财，这点和广东逢人便说帅哥可能有异曲同工之妙。云集路大早上没什么人，还蛮空的：今天准备去的洪江古商城和黔阳古城都没什么好玩的，说实话，不是很有看点。洪江古商城确实很差，黔阳古城稍微有点人气吧，还是可以逛逛。出发从怀化南客运站买票出发，其实微信公众号可以买，但是就只有电子票，怎么说呢，如果你想要留纸质票是可以线下过来买的，毕竟没多少人，反正随便买。从怀化南到洪江古商城需要一个半钟头的大巴，注意要看好汽运站上面对应的车牌号，不要上错车，车上其实是随便坐的。当然也有一些老年人可能比较较真，大家多体谅一下。中间是要路过黔城的，天气蛮不错的，坐大巴其实也可以略微欣赏一下沿途风景。哦，这里是黔城高铁站，但是吧，反正都是差不多的时间，坐高铁真不如坐大巴。现在我知道某人为什么对我发的风景照不是很感兴趣了，如果一直生活在山清水秀的地方，确实会对城市里面一些突然出现的风景完全没感觉。洪江古商城大巴车会指引各位下车，因为它就开去前面不远的客运站，反正洪江古商城是可以下车的，如果你实在觉得可能会坐过站，请直接跟司机大声说到洪江古商城指引你下车。（但是我觉得还是没必要来玩）可能是我来的有点早，反正没多少人，这个是比较标志性的建筑了，一把伞一个包袱，然后来洪江当老板。这地方开发的不怎么样，反而门票还要收你90块钱，我觉得真的没啥意思。只能说可以留个纪念，里面的明清时代的房子是有，但是能作为展示的一些参观地区不多，还有一些人在住，然后还有正在进行修缮的，整个景区弄得不是特别好。我怀疑钱都投到这里做一些摆设了。古城区花了钱进来可以看古城区，当然古城区没什么特别好看的。外面免费的新城区建设的也还行，但是主要没啥人，所以很多店铺也没有开放。整个古城区都是类似这种建筑，其实吧，这种建筑风格其实在潮汕地区也很多见。洪商开的几个展区，说实话不多，也就稍微可以看一看。古人的商训，现在要是这么做估计就没办法做买卖了。外面好像有卖这个的，但是应该运不回去哈哈哈。其实整个片区蛮多各种不同的会馆的，古时候这里应该也是一个交通枢纽和一些会馆驻扎的地方，类似于一个租界一样的感觉。里面是药王孙思邈，感觉还蛮常见。然后又要走一些很麻烦的台阶路，感觉古人也会觉得很痛苦吧，比较马车也动不了。接下来就可以看到这个很典型的宣传语了——一个包袱，一把伞，来到洪江，当老板。遇到了一个表演团的人，在讨论怎么作表演，但是说实话，居然不是训练好的，说明团队一直在变动，感觉应该没什么人来看导致的。据说洪江这地方的福字比较特殊，有洪福齐天的说法，当然这个是它自己标识的，我个人认为不可考据。没去动他，毕竟我也已经不是顽皮的小孩了，当然很可能棋子也是粘住的。这里搭了一个喜房，挺好的，怎么说呢，可能算是比较出乎我意料的一个展厅。常德会馆其实是碑文展，碑文其实怎么说呢，我是看不懂，但是写的确实好，神韵之类的吧，但是我艺术鉴赏能力有限，不是很感兴趣。镖局有表演，反正我是没看，因为据说只表演个2-3min，没啥意思。看一下关公还是可以的，左右两侧有兵器房，可以看一下一些冷兵器的形状，反正也肯定是假的。阿嫲在那里一通算，这个算盘是可以动的，被我拍的不好意思了哈哈哈，其实感觉她算的应该还蛮自信的。这就是以前地方收税的地方，粮食收成后，地方从民众手里收钱。看了一下有点意思，可能这边也有新的房子建设，emmm，不过谁买在这里，感觉都不好。哈哈哈，我真的好受小狗狗欢迎，上次去温州那只也是躺好了肚皮想让我rua，这次这只也是很想奔向我和我玩。这个是我爬上去打算看看有没有什么好玩的，结果发现并没有，好吧，上面就一户人家。新城区这个大佛寺其实香火还蛮鼎盛的，里面有很多老阿嫲在念经，现在谨慎一些了，反正不好拍里面的佛像，有兴趣的可以去新城区溜溜，我个人认为外面这层新城比里面有看头。根本没人来这里玩！我爬上上面的这个楼阁对着对面拍的。这里简述了洪江古商城的历史变更，说真的你这个放外面，我进去里面看什么了，里面一点说明和指引都没有，整个景区开发得稀巴烂。阿姨们在外面跳舞——社会主义好，哈哈哈，一方面感叹湖南确实受到毛主席影响颇深，另一方面就是感叹老年人的活力比年轻人强多了。这里有很多店铺基本没开，旁边都是一些住宅区就是一些民宿之类的，估计以前也旺过一阵，反正现在是完全不行。这个诗不错，而且蛮好看。不错不错，又遇到关帝爷了，感觉是个好事，再拍张照片。外面的酒店好豪华，不过应该蛮贵的吧。。。这个一整条街都有东西吃，如果逛完的话建议出来吃，里面的小吃都不行的。准备去黔阳古城了，这地方真的一点意思都没有，建议大家拔草。路过洪江一中。再出发于是我走到了洪江汽运站，为什么呢，因为这里可以直接买票前往黔阳古城。大家进入汽运站之后就是众生平等的，在这里可以看到来自四面八方的人。买了张去往黔城的车票，可以在黔阳古城下车，走了走了。黔阳古城很快就可以抵达黔阳古城，大概需要半小时左右吧，从洪江古商城到黔阳古城并不远，但是等大巴需要时间。来到黔阳古城了，人气明显更足一些，这会儿还蛮多外地游客来的，但是天气也比较热。如果非要在两个古商城推荐一个，我宁愿推荐黔阳古城，主要走起来方便，而且景区门票打半价（五一）。感觉黔阳古城更显得有生活气息。这个古城最主要可能就是出了一些名人，来这里都是来看一下芙蓉楼的。到晚上估计会亮灯，变成夜市的感觉。一般吧，我只能说，这玩意儿在哪个景区卖的都一样，味道也没什么变化。湖南人中午不是在躺着就是在打麻将。但是其实还是蛮惬意的。据说是拍恰同学少年的打卡位置。还是不错的，这个杨梅是甜的，比我这天晚上被坑的杨梅好多了。这个票票价要30，但是可以进入：芙蓉楼、王有为故居、城墙上面还有文庙。这里就是所谓的“洛阳亲友如相问，一片冰心在玉壶”的芙蓉楼了。还蛮好看的，立刻来了一张。和楚南第一楼拍张合照。然后就进入景区门口啦！然后去城墙！拍一下整个全景。其实就是个苏式的院子，也没什么特别的。看门检票的老大爷没能接住我的票哈哈哈，没事，最后安然无恙进去了。黔阳古城生活气息太热了猫猫都躺了，哈哈哈，有点松弛。当地居民自己放在古城的屋檐上晒拖鞋从古城上可以直接绕到黔城完小，还真是没想到。回怀化！两个古城都逛完了，热的要命，但是能玩的地方真的不多，所以现在回怀化。这里有个小插曲，首先我是坐的1号公交，结果它半路可以自己改为2号，真的绝了。幸好车上就我一个人，司机问我要去哪，我说去汽运站。到达汽运站之后呢，想进去里面买票，售票员阿姨告诉我直接上车补票就行，不用进入候车站了。她还跟我聊了一下以前是在广东中山电子厂做的，从流水线工人做到主管，可惜有了孩子就回到怀化了。到怀化开了十来年车行，现在又回来做售票员，挺辛苦的。一路抵达怀化南，然后我选择去鹤鸣洲看看！啥也没能看到，在旁边公园溜达一下吧。想去吃饭了，有点饿了，前往万达！晚上这个地方肯定会很热闹，不过我晚上不来这边。来怀化肯定要点怀化菜啊，来怀化名堂吃一吃。真不辣，这个我可以一直吃，哦，主要是太容易饱了哈哈哈，很下饭。晚上下了大雨，买了点杨梅在酒店里面哭唧唧呜呜呜。
怀化之旅-2025.5.2说实话，作为一个广东人，如果选择去湖南的话，应该首选是长沙、衡阳、郴州、岳阳之类的地方。我选择了怀化确实蛮神奇的，我来怀化的初心是感受一下这座城市的文化。出发从广州南站高铁站出发抵达怀化一共需要五个多小时，因为怀化地理位置比较偏僻，夹在贵州和广西中间，按理来说这种地理位置大概率是要出现很多山山水水的，果不其然，怀化的交通确实没有想象中的便利。我这天选择的是七点多的车，所以我还是蛮赶的，不过幸好无惊无险，通过了五一节的大考验。很多游客正在集中排队检票入站。路过郴州，阳光还算明媚，这趟车从广州往粤北开，第一站是韶关，反正这种路线肯定不是去长沙的，所以车上的人都是去衡阳之类的地方。在列车上消磨时间其实是很麻烦的，现在的我有点迷上了纪实文学，这本《饱食穷民》推荐给大家，讲述了关于社会经济状态变换后，人民对生活和精神双层的诱惑、打击和调解，还是蛮有看头。抵达由于本趟列车总共也就五个多小时，其实和我之前去福州上学时间是差不多的，所以还可以忍受，相对来说开往这个方向的列车时速还要更快一些，所以感受可能更好，从广州开往福州的列车反而因为时速变得很慢，让人可能难以忍受。出站就可以看到这个国际陆港的大招牌，当然实际上确实有这么个陆港，但是不在市内，往后可能会给大家pou上这个照片，怀化也是一个物流城市，跟攀枝花一样，也是火车拉来的城市。其实我是蛮兴奋的，不知道是来到一座新城市的兴奋呢，还是因为当时觉得还没弄丢自己的一个兴奋。午饭到了出站口去B1社会停车场就可以打车了，怀化打车相对方便，不愁打不到车，当然如果你恰好碰上那群去医学院的学生就除外。这是我第一天来到怀化，看到那个小笼包了吗？后续我会去吃它。怀化的公共交通确实不够便利，但是共享电动我还是会开的，不过在这里没有开，因为我怕不熟悉路况。后续在凤凰古城是真的开了，因为实在受不了打不到车。说实话这条街如果再拥挤一点真的很像香港，香港给我的压迫感太强了，幸好怀化不是。随便找了家农家餐馆，某个被我同样弄丢的人推荐了血鸭，于是迫不及待找了这家餐馆，打算吃一下。还不错，我发现出了广东的好处就是任何餐馆都愿意提供纸巾，这点广东能不能学一下？我没去过湖南其他地方，怀化这个地方的特点是直接给你上一大桶饭，老板娘见我一个人，说建议别点那么多菜了。我点了份血鸭也有可能吃不完，顺带点了份清蒸丝瓜（广东人真的很害怕吃辣）。血压味道不错，肉质韧性十足，同时还有点辣，就是肉其实可能没那么多。清蒸丝瓜我就不介绍了，这玩意儿在哪里吃都是那个味道。香洲桥因为某人推荐我去喝杯茶颜悦色，我原以为只有长沙有，没想到大湖南可能分布的店还蛮广，反正我吃饱了不饿正好溜达溜达。这里其实有个香洲公园，旁边怀化三中在这里有个停车场，离我想去的怀化博物馆也蛮近。大中午路上没多少人，天气有点热，但是还好，我比较兴奋。这里的路感觉根本没有修，怀化市应该对这段路再修理一下，这样别人接送孩子，或者接送老师都比较方便。今天能做的其实不多，反正逛一逛，主要看了博物馆和三中。前往茶颜悦色去找最近的茶颜悦色的路上需要先经过三中，远处拍了一张照片，后面再来会会它你或许无法理解怀化这条路为什么搞了个很大的地下通道，说实话这里面乌漆嘛黑的，如果不是有个儿童乐园，估计很难走这条路。我很怀疑怀化市民是不是走惯了，这里面的指示牌确实比较难分辨，主要是我不熟悉这些指示牌的地点，不过还好，导航还可以接受。差不多快到了，这里面有个商场，商场里就有我需要喝的东西了。茶颜悦色一如既往的多人，不过这个还好吧，至少应该比长沙人少了很多。点了杯幽兰拿铁，嗯，某人推荐的，确实好喝，怎么说呢，这玩意儿很容易上瘾，因为你喝了感觉跟没喝一个样子。就特别舒爽的茶底，我记得很久之前喜茶也这样，然后古茗的乌龙茶底没换之前也这样，就非常好喝，可惜现在已经完全不行了。公路局实在是没人给我指引了，迫不得已找了一下怀化的网友（其实也是以前认识的学生），让我去他老爸单位看看。还蛮不错的，感觉只是因为守门的在睡大觉，反正我进来他没能抓到我，但是后面醒来就开始盯着我了，不过怀化对市民包容度还是很高的，不会直接赶你出去。过来拍几张照片就遛了，总不能真的去拜访叔叔哈哈哈，不然影响人家工作（当然估计如果不是值班可能在家里睡大觉）相信我，如果看到毛主席的头像，你也会忍不住和他合影的。怀化市民一角来到一个地方必须了解一下当地的市民街角，这里给大家来几张比较有趣的。我觉得怀化市这条路修的是很不错，很宽敞，就是电动还是有点太猛了，不过要是我在这里生活可能也这样。这个建筑的风格也是蛮有意思的，过来的时候看了一下哈哈哈。怀化人民可能真的喜欢辣椒，在路上也能看到别人晒辣椒。这里是地下通道的菜市场，很多本地人在这里买水果和买菜（！！！傻了，早知道在这里买水果，酒店附近那家坑了我几十块的杨梅！！！！）怀化三中到三中来看看，首先是门口：在门口张望了一下，看了一下内部结构门卫放我进来了，看一下内部的结构：溜达了一圈又出来了，可以看看旁边的一些餐饮店，这种估计很久也不会变。看看校训留个合影吧！哈哈哈三中实力应该很强，门口有一整条文化长廊。旁边的体育馆更是盛大，没想到这么大的体育馆居然是给三中的。来看一下跑道，可惜没什么学生，可以回味一下当年跑操的感觉。怀化市博物馆逛完三中就该去今天旅行的目的地了——怀化博物馆，我很喜欢来到每个城市看一下当地的博物馆，因为文化来源、记载、这个城市的变迁乃至风土民情，人种的性情特征其实都可以从博物馆了解到一些。怀化博物馆设置挺有意思的，因为旁边就是一个书吧。在大城市这两个建筑应该是分离开的，不过在这里市博物馆承担文化遗产保护中心的职能，只能说有道理，但不多，因为这样的话博物挂的展厅基本上只能是固定的了，没有多余的人手去做一些设计。要预约，不过没多少人，所以直接现场微信公众号预约后扫码进去就行。只有一个展厅，和我预想中的一样，固定展的话就基本上是历史变迁的一些沿革。怀化文化传承至五溪文化，后又经过高庙文化，受到春秋战国时期楚国影响，哦，洞庭湖嘛，那好像又可以理解了。这里很有趣的一点是，居然这里是最先用到纸币的。文创由于怀化这地方其实蛮大，也有很多少数民族自治区，所以可以看到文创这方面也很独特但是吧，反正我没看到有卖的，可能来的不是时候。博物馆一角整个策展时间是从09年开始，这个展厅基本没有变过。其实建筑还蛮好的，很规正，外面看还是很舒服的一个场所。回去歇息我住的酒店在云集路，其实蛮不错的，旁边就是时代广场，吃的也不少。哦，不过这边很多足疗哈哈哈，有兴趣可以去，大概率应该是正规的。晚餐怀化的人都蛮友好的，老板娘让我自己加小料，然后还给了我吸管。。。。。反正在广州你吸管要自己找老板拿。嗯，有点辣哈哈哈！这个真的好吃！好甜！建议来吃粑粑柑。