1. 项目背景与整体架构设计1.1 为什么选择这套技术组合eleme 这类本地生活服务平台本质上是一个高并发、读多写少的交易系统。用户侧要扛住午晚高峰的瞬时流量商家侧要保证订单状态实时同步骑手侧要处理位置上报和调度计算。这三个场景对后端的要求完全不同所以架构上必须做分层。我当时的选型逻辑是这样的接入层用LVS做四层负载均衡因为它在内核态转发单机轻松扛几十万并发连接比 Nginx 七层转发在纯流量分发场景下更省资源。业务层用Java写Spring Boot 起服务原因是团队里 Java 人多生态成熟排查问题资料全。数据层用MySQL做主存储单表数据量上来之后用Mycat做分库分表中间件把订单表按用户 ID 哈希拆到多个物理库。这套组合不是最时髦的但胜在每一层都有大量生产案例出问题能查到资料。我见过一些团队上来就搞 NewSQL结果运维成本高得离谱招人都招不到。对于大多数中等规模的平台LVS Java MySQL Mycat 这套老牌组合反而是最稳的。1.2 整体流量链路拆解一次用户下单请求的完整路径是这样的客户端请求先到 DNS解析到 LVS 的虚拟 IP。LVS 根据调度算法把连接转发到后端的 Java 网关节点。网关做鉴权和限流后把请求透传给订单服务。订单服务先查缓存缓存没命中就查 MySQL。如果订单表已经分库分表Mycat 会根据分片键路由到具体的物理库。这里有个关键点LVS 只负责四层转发不解析 HTTP 内容。所以像灰度发布、按 URL 路由这种需求得在 Java 网关层做。我当时的做法是 LVS 后面挂一组 Nginx 做七层分流Nginx 再转发给 Java 服务。这样既利用了 LVS 的高吞吐又保留了七层的灵活性。注意LVS 的 DR 模式要求真实服务器和 LVS 在同一个二层网络且真实服务器要绑定 VIP 到 lo 接口并关闭 ARP 响应。这一步配置错了会出现 VIP 冲突导致整个机房网络抖动我踩过这个坑排查了整整一个下午。1.3 容量预估与机器规划上线前我做了个简单的容量测算。假设日订单量 50 万午高峰集中在 11:30 到 12:30 这一个小时按 60% 订单集中在这个时段算峰值 QPS 大约是 50万 × 0.6 / 3600 ≈ 83。但下单请求往往伴随多次查询实际读 QPS 要乘以 5 到 8 倍所以按 500 QPS 来设计。Java 服务单节点压测下来能扛 800 QPS 左右为了留冗余部署 4 个节点。MySQL 主库写 QPS 按 100 设计单机足够。从库部署 2 个分担报表和后台查询。Mycat 部署 2 个节点做高可用用 Keepalived 做 VIP 漂移。LVS 用主备模式避免单点。这套规划后来实际跑下来午高峰 CPU 峰值在 40% 左右说明冗余留得比较合理。机器不是越多越好多了反而增加运维复杂度和成本。2. 核心组件部署与配置实操2.1 LVS 负载均衡的安装与 DR 模式配置LVS 现在一般直接用 Linux 内核自带的 IPVS 模块不需要额外编译。先确认内核支持lsmod | grep ip_vs如果没有输出加载模块modprobe ip_vs modprobe ip_vs_rr安装管理工具 ipvsadmyum install -y ipvsadmDR 模式的配置分两部分。LVS 节点上执行ipvsadm -A -t 192.168.1.100:80 -s rr ipvsadm -a -t 192.168.1.100:80 -r 192.168.1.11:80 -g ipvsadm -a -t 192.168.1.100:80 -r 192.168.1.12:80 -g真实服务器上要绑定 VIP 到 lo 并抑制 ARPifconfig lo:0 192.168.1.100 netmask 255.255.255.255 up echo 1 /proc/sys/net/ipv4/conf/lo/arp_ignore echo 2 /proc/sys/net/ipv4/conf/lo/arp_announce echo 1 /proc/sys/net/ipv4/conf/all/arp_ignore echo 2 /proc/sys/net/ipv4/conf/all/arp_announce调度算法我选的rr轮询简单够用。如果后端机器配置不一致可以用wrr加权轮询。实际生产里我还加了个健康检查脚本定时 curl 后端服务的健康接口挂了就ipvsadm -d摘掉。实操心得LVS 本身不做健康检查这是它和 Nginx 最大的区别。很多人以为配好 ipvsadm 就完事了结果后端服务挂了流量还在往那台机器打。一定要自己写个检查脚本挂 crontab或者用 keepalived 的real_server配置来做。2.2 Java 服务的环境搭建与启动参数调优Java 环境我用的 OpenJDK 17安装很简单yum install -y java-17-openjdk java-17-openjdk-devel配置环境变量编辑/etc/profileexport JAVA_HOME/usr/lib/jvm/java-17-openjdk export PATH$JAVA_HOME/bin:$PATH export CLASSPATH.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jarsource /etc/profile之后java -version验证。启动参数这块我调了好几轮。最初用默认参数GC 停顿经常飙到 500ms 以上。后来改成 G1 收集器java -Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis200 \ -XX:InitiatingHeapOccupancyPercent45 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump/ \ -jar eleme-order.jar-Xms和-Xmx设成一样大避免堆动态扩容带来的抖动。G1 的MaxGCPauseMillis设 200ms实测下来 Young GC 平均 30msFull GC 基本没触发过。有个坑要提醒Java 17 编译的 class 文件在低版本 JVM 上跑会报源发行版 17 需要目标发行版 17的警告这其实是 Maven 编译插件没配对。在pom.xml里显式指定plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration source17/source target17/target /configuration /plugin2.3 MySQL 安装与关键参数配置MySQL 我选的 8.0 版本用官方 yum 源安装rpm -Uvh https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm yum install -y mysql-community-server systemctl start mysqld首次启动后从日志里拿临时密码grep temporary password /var/log/mysqld.log登录后改密码并设置远程访问ALTER USER rootlocalhost IDENTIFIED BY YourStrongPass123!; CREATE USER eleme% IDENTIFIED BY ElemePass123!; GRANT ALL PRIVILEGES ON eleme.* TO eleme%; FLUSH PRIVILEGES;my.cnf里几个关键参数[mysqld] character-set-serverutf8mb4 collation-serverutf8mb4_unicode_ci max_connections2000 innodb_buffer_pool_size8G innodb_log_file_size1G innodb_flush_log_at_trx_commit2 sync_binlog100 slow_query_log1 long_query_time1innodb_buffer_pool_size设成物理内存的 60% 到 70%这是 InnoDB 性能最关键的一个参数。innodb_flush_log_at_trx_commit设 2 表示每次事务提交写 OS 缓存每秒刷盘一次牺牲一点持久性换性能。如果是支付类业务这个值必须设 1不能妥协。注意error 2002 (hy000): cant connect to local mysql server through socket /tmp/mysql.sock这个报错九成是 mysqld 没启动或者 socket 文件路径和客户端配置不一致。先systemctl status mysqld看服务状态再看my.cnf里的socket配置。2.4 Mycat 分库分表中间件部署Mycat 依赖 Java 环境先确认 JDK 装好。下载解压后主要改三个配置文件。server.xml里配置 Mycat 的登录用户user nameroot defaultAccounttrue property namepassword123456/property property nameschemaseleme/property /userschema.xml定义逻辑库和分片规则schema nameeleme checkSQLschemafalse sqlMaxLimit100 table namet_order dataNodedn1,dn2,dn3,dn4 rulemod-long / /schema dataNode namedn1 dataHosthost1 databaseeleme_0 / dataNode namedn2 dataHosthost1 databaseeleme_1 / dataNode namedn3 dataHosthost2 databaseeleme_2 / dataNode namedn4 dataHosthost2 databaseeleme_3 /rule.xml里mod-long规则按user_id取模分片tableRule namemod-long rule columnsuser_id/columns algorithmmod-long/algorithm /rule /tableRule function namemod-long classio.mycat.route.function.PartitionByMod property namecount4/property /function分片键选user_id而不是order_id是因为查询订单列表时都是按用户维度查的这样能保证同一个用户的订单落在同一个库避免跨库查询。代价是后台按订单号查订单时需要走全局索引表这个后面会讲。3. 数据层设计与性能优化3.1 订单表分库分表策略订单表是增长最快的表日增 50 万行一年就是 1.8 亿行。单表超过 2000 万行后即使有索引B 树深度也会增加查询性能明显下降。所以分库分表是必须的。我按user_id哈希取模分成 4 个库每个库再按月份分表比如t_order_202401、t_order_202402。这样单个物理表的数据量控制在 500 万行以内查询走索引基本是毫秒级。分表带来的问题是跨表查询。比如后台要查某个订单号不知道它在哪个库哪张表。解决方案是建一张全局索引表t_order_index记录order_id到user_id和分片信息的映射。写入订单时同步写索引表查询时先查索引表定位再查具体分表。CREATE TABLE t_order_index ( order_id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, shard_node VARCHAR(32) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id) ) ENGINEInnoDB;这张索引表本身也可能成为瓶颈所以我又给它加了 Redis 缓存热点订单的映射关系直接走缓存。3.2 数据库连接池配置与调优Java 侧用 HikariCP 连接池配置如下spring: datasource: hikari: minimum-idle: 10 maximum-pool-size: 50 idle-timeout: 300000 connection-timeout: 3000 max-lifetime: 1800000 connection-test-query: SELECT 1maximum-pool-size设 50 是算出来的。单节点 Java 服务峰值 QPS 800每个请求平均持有连接 20ms那么需要的连接数大约是 800 × 0.02 16。留三倍冗余设 50 足够。设太大反而会导致 MySQL 侧连接数爆掉max_connections只有 20004 个 Java 节点每个 50 就是 200加上 Mycat 和其他服务完全在安全范围内。max-lifetime设 30 分钟比 MySQL 的wait_timeout短避免拿到已经被服务端关闭的死连接。这个参数很多人忽略结果线上偶发Communications link failure查半天查不出来。3.3 慢查询治理与索引优化上线后我开了慢查询日志long_query_time设 1 秒。第一天就抓到几条慢 SQL最典型的是订单列表查询SELECT * FROM t_order WHERE user_id ? ORDER BY create_time DESC LIMIT 20;user_id上有索引但create_time没有导致排序时用了 filesort。改成联合索引ALTER TABLE t_order ADD INDEX idx_user_time (user_id, create_time DESC);再跑一次Extra列从Using filesort变成Using index condition耗时从 800ms 降到 5ms。还有个坑是SELECT *订单表有几十个字段其中还有几个 TEXT 类型的大字段。查列表时根本不需要这些字段但SELECT *全查出来了网络传输和内存占用都浪费。后来改成只查需要的字段单次查询的数据量减少了 70%。实操心得MySQL 8.0 支持降序索引create_time DESC这种写法是有效的。如果是 5.7 版本降序索引会被忽略得用其他方式优化。另外EXPLAIN看执行计划时重点关注type列ALL是全表扫描index是全索引扫描range是范围扫描ref和eq_ref是比较理想的。4. 上线过程中的问题排查与解决4.1 LVS 转发异常排查上线第一天下午监控报警显示部分用户下单超时。登录 LVS 节点用ipvsadm -L -n看连接状态发现有一台真实服务器的 ActiveConn 特别高其他两台很低。轮询算法理论上应该均匀分配出现这种情况说明那台机器的响应特别慢连接堆积了。登到那台机器上看Java 进程 CPU 正常但 GC 日志显示 Full GC 频繁。原因是那台机器上还跑了其他服务内存被挤占了。把其他服务迁走调整 JVM 堆大小后恢复正常。排查 LVS 问题的常用命令ipvsadm -L -n --stats # 查看转发统计 ipvsadm -L -n --rate # 查看转发速率 ipvsadm -L -n --timeout # 查看连接超时设置如果发现某个后端连接数异常高先检查那台机器的服务状态再看网络是否有丢包。4.2 MySQL 主从延迟处理为了保证读扩展我配了主从复制。上线后发现从库延迟经常飙到几十秒后台报表查询读到旧数据运营那边反馈订单状态不对。排查思路先看SHOW SLAVE STATUS里的Seconds_Behind_Master确认延迟。然后看主库的binlog生成速度如果写入量太大从库单线程回放跟不上就会延迟。解决方案是开并行复制。MySQL 8.0 默认就是并行复制但slave_parallel_workers默认是 0要手动设SET GLOBAL slave_parallel_workers 8; SET GLOBAL slave_parallel_type LOGICAL_CLOCK;改完之后延迟从几十秒降到 1 秒以内。另外把sync_binlog设成 100减少刷盘频率也能缓解主库压力。4.3 Mycat 分片查询的常见坑Mycat 用起来方便但有几个坑必须提前知道。第一个是跨分片查询。如果查询条件里没有分片键Mycat 会把 SQL 发到所有分片执行然后合并结果。数据量小的时候没问题数据量大了就是灾难。我遇到过一条没有user_id条件的查询Mycat 并发查了 4 个库共 48 张表直接把数据库连接池打满。解决办法是在 Mycat 里配置sqlMaxLimit限制返回行数同时在业务层强制要求查询必须带分片键。后台管理类的查询走单独的只读从库不经过 Mycat。第二个是全局序列号。分库分表后AUTO_INCREMENT不能用了得用 Mycat 的全局序列。我用的本地时间戳方式system property namesequnceHandlerType2/property /system然后在sequence_conf.properties里配置ORDER.HISIDS ORDER.MINID1000 ORDER.MAXID200000 ORDER.CURID1000这种方式性能好但重启 Mycat 后可能产生重复 ID所以我又加了个基于 Redis 的分布式 ID 生成器作为兜底。4.4 常见问题速查表问题现象可能原因排查命令解决方案LVS 转发不均后端响应时间差异大ipvsadm -L -n --stats检查慢节点调整权重或摘除MySQL 连接超时连接池配置不当SHOW PROCESSLIST调整max-lifetime和wait_timeoutMycat 查询慢跨分片查询Mycat 日志看路由结果强制带分片键或走从库Java Full GC 频繁堆内存不足或泄漏jstat -gcutil调大堆用 MAT 分析 dump主从延迟高单线程回放SHOW SLAVE STATUS开并行复制增加 worker 数5. 上线后的稳定性保障与扩展思考5.1 监控体系搭建上线不是终点稳定运行才是。我搭了一套监控分三层。基础设施层用 Prometheus Node Exporter 采集 CPU、内存、磁盘、网络。LVS 的指标通过ipvsadm导出到 Prometheus。Java 应用层用 Micrometer 暴露 JVM 指标包括堆内存、GC 次数、线程数。业务层自定义了几个关键指标下单成功率、支付成功率、平均响应时间。告警规则设了这几条下单成功率低于 99% 持续 1 分钟告警Java 服务 P99 响应时间超过 500ms 告警MySQL 主从延迟超过 5 秒告警LVS 后端全部不可用告警。实操心得告警阈值不要设得太敏感否则天天被误报轰炸最后大家都不看告警了。我一般先观察一周的基线数据取 P95 或 P99 作为阈值参考再留 20% 的余量。5.2 压测与容量再评估上线前我用 JMeter 做了一轮压测模拟 1000 并发用户持续下单。结果发现 Java 服务在 600 QPS 时响应时间开始明显上升瓶颈在数据库连接池。把maximum-pool-size从 30 调到 50 后能稳定跑到 900 QPS。压测时要注意几点压测环境的数据量要和生产接近否则索引效果不一样压测流量要打散不要集中在一个用户上否则缓存命中率虚高压测后要清理测试数据避免污染生产库。5.3 后续扩展方向这套架构目前支撑日订单 50 万没问题但如果业务继续增长有几个扩展方向。数据库层面可以从 4 个分片扩展到 8 个或 16 个。Mycat 支持在线扩容但需要做数据迁移建议在低峰期操作。缓存层面可以引入 Redis 集群把热点数据全部放到缓存减少数据库压力。服务层面可以把订单服务拆成下单、查询、状态同步三个独立服务各自独立扩容。还有个方向是引入消息队列削峰。午高峰的下单请求先写 MQ后端服务按自己的能力消费避免瞬时流量把系统打垮。这个改造对业务代码有侵入需要评估投入产出比。我个人在实际操作中的体会是架构演进要跟着业务走不要为了技术而技术。eleme 这套项目上线跑了一年多中间做过几次小调整但核心架构没大改说明当初的选型是合理的。技术方案没有绝对的好坏适合团队、适合业务阶段的才是最好的。