news 2026/10/25 0:30:51

Spark JDBC连接稳定性:c3p0连接池实战调优与故障排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spark JDBC连接稳定性:c3p0连接池实战调优与故障排查

1. 为什么在Spark里硬要上c3p0?——一个被低估的连接稳定性问题

“Spark作业跑着跑着就卡住,日志里反复出现Connection refused或No suitable driver found,重试几次又好了”——这是某数据平台团队连续三个月每周都会收到的告警。他们用的是标准的JDBC写入MySQL方案,Driver端用spark.sql.jdbc.connectionTimeout=30s,Executor端靠foreachPartition拉连接,看起来天衣无缝。直到某次大促压测,单个Executor需向数据库批量写入27万条订单明细,连接池瞬间打满,后续任务全部排队等待,整个ETL链路延迟飙升到42分钟,远超SLA要求的5分钟阈值。

这时候才有人翻出那本积灰的《Spark性能调优白皮书》第7章:“JDBC连接管理不是‘能连上就行’,而是‘连得稳、断得清、收得尽’”。而c3p0,这个2003年诞生、2015年后逐渐淡出主流推荐的Java连接池,恰恰在连接泄漏检测、异常连接自动剔除、多线程并发初始化保护这三个冷门但致命的环节上,比HikariCP和Druid更“固执”——它不信任你写的close(),也不相信JVM的GC能及时回收Connection对象。

我试过直接把HikariCP的HikariDataSource塞进foreachPartition闭包里,结果在Spark 3.3.0 + JDK 11环境下,当Executor因OOM被YARN强制Kill后,残留的未关闭连接会持续占用MySQL的max_connections配额长达18分钟(MySQL默认wait_timeout=600s),导致新提交的作业集体失败。而c3p0的unreturnedConnectionTimeout=30配合debugUnreturnedConnectionStackTraces=true,能在连接泄露发生后30秒内主动中断并打印堆栈,把故障定位时间从小时级压缩到秒级。

这不是怀旧,是拿稳定性换开发效率。c3p0的XML配置冗长、监控指标简陋、Spring Boot Starter支持弱,但它在Spark这种“进程生命周期不可控、错误传播路径极深”的分布式场景下,提供了一种近乎“物理隔离”的容错保障:每个Executor进程启动时独立加载c3p0配置,连接池状态完全隔离,不会因上游某个Task的连接泄漏污染整个Executor的JDBC资源。这正是它在金融、电信等强一致性场景中仍被部分团队保留的核心原因。

提示:c3p0不是为高吞吐设计的,它的acquireIncrement=3和maxPoolSize=15默认值在Spark中往往过于保守。实测发现,当单个Executor需并发处理5个partition、每个partition写入10万行时,maxPoolSize至少需设为分区数 × 2 + 5,否则会出现大量com.mchange.v2.resourcepool.TimeoutException: A client timed out while waiting to acquire a resource from com.mchange.v2.resourcepool.BasicResourcePool@xxxxx。

2. c3p0配置文件的隐式陷阱:为什么你的c3p0-config.xml总被忽略?

Spark作业打包成jar后,c3p0-config.xml放在src/main/resources下,本地spark-shell测试一切正常,但提交到YARN集群就报java.lang.NoClassDefFoundError: com/mchange/v2/c3p0/ComboPooledDataSource——这是最经典的“配置生效但类找不到”双杀问题。根源在于Spark的ClassLoader机制:Driver端用AppClassLoader加载c3p0,而Executor端由ExecutorClassLoader加载,后者默认不继承Driver的classpath,除非你显式声明。

我们曾在一个模拟项目X中复现该问题:作业依赖c3p0:0.9.5.5和mysql-connector-java:8.0.33,c3p0-config.xml内容如下:

<c3p0-config> <default-config> <property name="driverClass">com.mysql.cj.jdbc.Driver</property> <property name="jdbcUrl">jdbc:mysql://db-host:3306/test?useSSL=false&amp;serverTimezone=UTC</property> <property name="user">spark_user</property> <property name="password">spark_pass</property> <property name="initialPoolSize">5</property> <property name="minPoolSize">5</property> <property name="maxPoolSize">20</property> <property name="acquireIncrement">5</property> <property name="maxStatements">100</property> <property name="maxStatementsPerConnection">5</property> <property name="checkoutTimeout">30000</property> <property name="acquireRetryAttempts">3</property> <property name="acquireRetryDelay">1000</property> <property name="breakAfterAcquireFailure">false</property> <property name="testConnectionOnCheckin">true</property> <property name="testConnectionOnCheckout">false</property> <property name="idleConnectionTestPeriod">300</property> <property name="unreturnedConnectionTimeout">60</property> <property name="debugUnreturnedConnectionStackTraces">true</property> </default-config> </c3p0-config>

问题出在三个被90%开发者忽略的细节上:

2.1 XML实体转义必须手动处理

&在XML中是非法字符,serverTimezone=UTC中的&必须写成&amp;。若直接复制粘贴URL,c3p0解析器会静默失败,降级使用默认配置(jdbcUrl=null),最终报SQLException: No suitable driver。这不是Spark的问题,是XML规范本身的要求。

2.2 文件名和路径有严格约定

c3p0只认c3p0-config.xml(全小写,带短横线),且必须位于classpath根目录。若你把它放在src/main/resources/config/c3p0-config.xml,c3p0将完全无视。更隐蔽的是:当项目同时存在c3p0.properties和c3p0-config.xml时,c3p0优先读取properties文件,XML配置彻底失效。

2.3 Spark提交时的依赖传递漏洞

即使pom.xml中声明了<scope>compile</scope>,Maven Shade Plugin若未配置<artifactSet>排除c3p0,会导致jar包内出现多个版本的c3p0类(如0.9.2和0.9.5.5共存),触发LinkageError。我们在某高校实验室的离线分析平台中就遇到过:基础镜像预装了c3p0 0.9.2,而作业jar自带0.9.5.5,Executor启动时随机加载任一版本,导致ComboPooledDataSource构造函数签名不匹配。

解决方案分三步走:

  1. 构建阶段:在pom.xml中用Maven Shade Plugin强制统一版本,并排除冲突依赖:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.SparkJob</mainClass> </transformer> </transformers> <filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> </excludes> </filter> </filters> <artifactSet> <includes> <include>c3p0:c3p0</include> <include>mysql:mysql-connector-java</include> </includes> </artifactSet> </configuration> </execution> </executions> </plugin>
  1. 提交阶段:用--jars参数显式指定c3p0 jar,确保ExecutorClassLoader优先加载:
spark-submit \ --master yarn \ --deploy-mode cluster \ --jars /path/to/c3p0-0.9.5.5.jar,/path/to/mysql-connector-java-8.0.33.jar \ --class com.example.SparkJob \ spark-job-1.0.jar
  1. 运行时验证:在Executor端代码中插入诊断逻辑:
import com.mchange.v2.c3p0.{ComboPooledDataSource, DataSources} import java.sql.DriverManager // 在foreachPartition内部执行 val ds = new ComboPooledDataSource() println(s"c3p0 config loaded: ${ds.getJdbcUrl}, pool size: ${ds.getNumConnectionsDefaultUser}") // 输出应为:c3p0 config loaded: jdbc:mysql://..., pool size: 5

注意:DataSources.destroy(ds)必须在partition处理完毕后调用,否则连接池对象会随闭包被序列化到Driver端,引发NotSerializableException。这是Spark序列化机制与c3p0对象图深度耦合导致的经典坑。

3. Spark JDBC写入的连接生命周期管理:从Driver到Executor的完整链路

Spark SQL的DataFrameWriter.jdbc()方法看似封装了所有细节,实则隐藏了三层连接管理模型:Driver端元数据连接、Executor端数据写入连接、以及c3p0内部的物理连接池。这三层的生命周期错位,正是连接泄漏的温床。

以一个典型场景为例:某跨平台系统需将用户行为日志(Parquet格式)按天分区写入MySQL的user_event_20240520表。代码如下:

val df = spark.read.parquet("hdfs://namenode:8020/logs/20240520") df.write .mode(SaveMode.Append) .option("url", "jdbc:mysql://db-host:3306/test") .option("dbtable", "user_event_20240520") .option("user", "spark_user") .option("password", "spark_pass") .option("batchSize", "10000") .format("jdbc") .save()

这段代码在Driver端会创建一个DriverJDBCWrapper,它仅用于获取表结构(getSchema)、生成INSERT语句模板。真正的连接发生在Executor端:每个Task调用JdbcUtils.savePartition()时,通过DriverManager.getConnection()获取连接。此时若未集成c3p0,每个Task每次写入batch都会新建连接,写完立即关闭——这在小数据量时可行,但在高并发下会触发MySQL的max_connect_errors限制。

而集成c3p0后,连接管理链路变为:

Driver JVM ├── 创建c3p0 DataSource实例(仅用于序列化) └── 序列化DataSource到Executor(注意:c3p0对象本身不可序列化!) Executor JVM(每个Executor进程独立) ├── 反序列化时触发c3p0静态初始化 │ └── 加载c3p0-config.xml → 创建新的ComboPooledDataSource实例 ├── 每个Task调用DataSource.getConnection() │ ├── 池中若有空闲连接 → 直接返回 │ └── 池满且未达maxPoolSize → 创建新连接 └── Task结束时调用connection.close() ├── 连接归还至池中(非物理关闭) └── 若连接异常(如网络中断)→ c3p0自动标记为invalid并销毁

关键洞察在于:c3p0的连接池是在每个Executor JVM内独立创建的,而非跨Executor共享。这意味着你在Driver端设置的maxPoolSize=20,实际效果是“每个Executor最多持有20个连接”,而非“整个Application共20个连接”。当集群有100个Executor时,MySQL可能面临2000个并发连接——这正是max_connections被耗尽的根源。

我们通过JMX监控验证了这一点:在YARN Web UI中进入Executor进程的JMX页面,查询com.mchange.v2.c3p0:type=PooledDataSource*MBean,可看到每个Executor的numConnectionsAllUsers指标独立增长。某次压测中,50个Executor的numConnectionsAllUsers峰值分别为18、19、17...平均18.3,证实了连接池的隔离性。

因此,c3p0的参数调优必须基于Executor数量反推:

  • 设定目标:MySQLmax_connections=5000,预留10%给其他服务 → 可用连接数4500
  • 预估Executor数:YARN队列最大容器数=200,但实际并发Executor数≈150(考虑动态分配)
  • 计算单ExecutormaxPoolSize:4500 ÷ 150 = 30,再乘以1.2安全系数 →36
  • 同时设置minPoolSize=10,避免冷启动时频繁创建连接

更进一步,我们发现acquireIncrement参数在Spark中需特殊处理。默认值3意味着当池中连接不足时,每次新增3个连接。但在foreachPartition场景下,一个Executor可能同时处理5个partition,若acquireIncrement=3,首次请求会创建3个连接,剩余2个partition需等待连接释放或再次触发创建,造成线程阻塞。实测将acquireIncrement设为分区数(如5)后,写入吞吐提升23%,因为连接能一次性预热到位。

提示:testConnectionOnCheckout=true在Spark中是双刃剑。开启后每次getConnection()都执行SELECT 1,增加网络往返;关闭后依赖idleConnectionTestPeriod=300(5分钟)定期检测,但可能让失效连接存活更久。我们的折中方案是:生产环境设为false,配合unreturnedConnectionTimeout=60快速回收泄漏连接。

4. 从零手写c3p0连接工厂:绕过XML配置的硬核方案

当项目需要动态切换数据库(如灰度发布时部分流量写入新库)、或需根据分区字段计算连接参数(如按用户ID哈希路由到不同MySQL实例)时,硬编码的c3p0-config.xml就成了枷锁。此时必须放弃XML,改用编程式配置——这正是c3p0最被低估的能力:它提供了完整的Java API来构建ComboPooledDataSource。

以下是一个为Spark定制的C3P0DataSourceFactory,它解决了三个核心痛点:

  • 动态URL注入:从Spark配置中读取spark.c3p0.jdbc.url.template
  • 密码解密:集成AES解密逻辑,避免明文密码硬编码
  • 连接池隔离:为每个Executor生成唯一标识,防止多作业间配置污染
import com.mchange.v2.c3p0.ComboPooledDataSource import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec import org.apache.spark.SparkConf object C3P0DataSourceFactory { // 全局缓存,避免重复创建相同配置的DataSource private val dataSourceCache = scala.collection.mutable.Map[String, ComboPooledDataSource]() def getDataSource(conf: SparkConf): ComboPooledDataSource = { // 生成唯一key:基于Spark App ID + 配置哈希 val appId = conf.getOption("spark.app.id").getOrElse("local") val urlTemplate = conf.get("spark.c3p0.jdbc.url.template", "jdbc:mysql://localhost:3306/test") val user = conf.get("spark.c3p0.user", "spark") val encryptedPass = conf.get("spark.c3p0.password.encrypted", "") val key = s"$appId-$urlTemplate-$user-${encryptedPass.take(5)}" dataSourceCache.synchronized { dataSourceCache.getOrElseUpdate(key, createDataSource(conf)) } } private def createDataSource(conf: SparkConf): ComboPooledDataSource = { val ds = new ComboPooledDataSource() // 1. 动态构建JDBC URL val urlTemplate = conf.get("spark.c3p0.jdbc.url.template") val dateStr = conf.get("spark.c3p0.date.partition", "20240520") val finalUrl = urlTemplate.replace("{date}", dateStr) ds.setJdbcUrl(finalUrl) // 2. 用户认证(含解密) ds.setUser(conf.get("spark.c3p0.user")) ds.setPassword(decryptPassword(conf.get("spark.c3p0.password.encrypted"))) // 3. 连接池核心参数(全部从Spark Conf读取) ds.setInitialPoolSize(conf.getInt("spark.c3p0.initialPoolSize", 5)) ds.setMinPoolSize(conf.getInt("spark.c3p0.minPoolSize", 5)) ds.setMaxPoolSize(conf.getInt("spark.c3p0.maxPoolSize", 36)) ds.setAcquireIncrement(conf.getInt("spark.c3p0.acquireIncrement", 5)) // 4. 健康检查参数 ds.setCheckoutTimeout(conf.getInt("spark.c3p0.checkoutTimeout", 30000)) ds.setAcquireRetryAttempts(conf.getInt("spark.c3p0.acquireRetryAttempts", 3)) ds.setAcquireRetryDelay(conf.getInt("spark.c3p0.acquireRetryDelay", 1000)) ds.setUnreturnedConnectionTimeout(conf.getInt("spark.c3p0.unreturnedConnectionTimeout", 60)) ds.setDebugUnreturnedConnectionStackTraces(true) // 5. 关键:禁用自动绑定Driver(Spark已加载) ds.setForceUseNamedDriverClass(false) ds } private def decryptPassword(encrypted: String): String = { if (encrypted.isEmpty) return "" // 实际项目中应对接KMS或Vault,此处简化为AES-128 val keyBytes = "1234567890123456".getBytes("UTF-8") val keySpec = new SecretKeySpec(keyBytes, "AES") val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, keySpec) new String(cipher.doFinal(java.util.Base64.getDecoder.decode(encrypted)), "UTF-8") } }

在Spark作业中使用:

// Driver端:广播配置(非广播DataSource对象!) val confBroadcast = spark.sparkContext.broadcast(spark.conf) // Executor端:在foreachPartition中创建连接 df.foreachPartition { partition => val conf = confBroadcast.value val ds = C3P0DataSourceFactory.getDataSource(conf) try { partition.foreach { row => val conn = ds.getConnection() // 从池中获取 try { val stmt = conn.prepareStatement("INSERT INTO user_event VALUES (?, ?, ?)") stmt.setString(1, row.getString(0)) stmt.setString(2, row.getString(1)) stmt.setLong(3, row.getLong(2)) stmt.execute() } finally { if (stmt != null) stmt.close() } } } finally { // 重要:不调用ds.close()!这会销毁整个池 // c3p0会自动管理连接生命周期 } }

这个方案的价值在于:把连接池配置从静态文件升级为运行时策略。例如,当需要对user_event_20240520表启用读写分离时,只需在提交命令中添加:

--conf spark.c3p0.jdbc.url.template="jdbc:mysql:replication://master:3306,slave1:3306,slave2:3306/test"

而无需修改任何代码或重新打包jar。某公司就在大促前2小时,通过调整spark.c3p0.maxPoolSize=50将写入吞吐从12万条/分钟提升至28万条/分钟,全程零代码变更。

注意:ds.close()绝对不能在Executor中调用!这会触发BasicResourcePool.dispose(),销毁当前Executor内所有连接,导致后续Task无法获取连接。c3p0的正确关闭时机是Executor JVM退出时,由JVM Shutdown Hook自动执行。

5. 故障排查实战:一次连接池耗尽的完整溯源过程

某次凌晨三点,某电商平台的实时风控作业突然告警:Failed to insert into MySQL: com.mchange.v2.resourcepool.TimeoutException。值班工程师重启作业后暂时恢复,但两小时后再次失败。我们介入后,按以下步骤完成了从现象到根因的完整排查:

5.1 现象确认:区分是连接池满还是连接泄漏

首先登录YARN ResourceManager UI,定位到失败的ApplicationMaster日志,搜索关键词TimeoutException,发现高频报错:

com.mchange.v2.resourcepool.TimeoutException: A client timed out while waiting to acquire a resource from com.mchange.v2.resourcepool.BasicResourcePool@7a8b9c0d

这明确指向c3p0连接池耗尽,而非网络或认证问题。

接着检查Executor日志,发现两个矛盾现象:

  • 所有Executor的numBusyConnectionsAllUsers指标稳定在maxPoolSize=36
  • 但numConnectionsAllUsers却持续增长,最高达42(超出maxPoolSize)

这违反了c3p0的设计原则:numConnectionsAllUsers不应超过maxPoolSize。唯一解释是:存在连接未归还至池中,即连接泄漏。

5.2 泄漏点定位:利用c3p0的堆栈追踪功能

在c3p0-config.xml中启用调试:

<property name="unreturnedConnectionTimeout">60</property> <property name="debugUnreturnedConnectionStackTraces">true</property>

重启作业后,日志中开始出现:

WARNING: com.mchange.v2.resourcepool.BasicResourcePool$ScatteredAcquireTask@12345678 -- Acquisition Attempt Failed!!! Clearing pending acquires. While trying to acquire a new resource, we failed to check out a resource from the pool within the specified acquisition retry attempt period. ... DEBUG: com.mchange.v2.c3p0.impl.NewPooledConnection@abcdef01 has been checked out for 62 seconds! Stack trace: at com.example.RiskProcessor.processEvent(RiskProcessor.scala:45) at com.example.RiskProcessor.$anonfun$processBatch$1(RiskProcessor.scala:78) ...

堆栈清晰指向RiskProcessor.scala第45行——一个未包裹在try-finally中的PreparedStatement.execute()调用。代码片段如下:

// BUGGY CODE def processEvent(row: Row): Unit = { val conn = ds.getConnection() val stmt = conn.prepareStatement("INSERT ...") // 第45行 stmt.setString(1, row.getString(0)) stmt.execute() // 若此处抛异常,stmt和conn均不会关闭! }

5.3 根因分析:Spark的异常传播机制放大了泄漏

Spark的foreachPartition中,若单个Task内抛出未捕获异常,整个partition处理会中断,但已获取的连接不会自动归还。c3p0的unreturnedConnectionTimeout机制虽能60秒后强制回收,但在此期间连接处于“已借出未归还”状态,持续占用池容量。

我们用JFR(Java Flight Recorder)抓取了故障期间的内存快照,发现com.mchange.v2.c3p0.impl.NewPooledConnection对象数量与java.sql.Connection数量不一致,差值恰好等于泄漏连接数,证实了泄漏的存在。

5.4 修复与验证

修复代码采用标准的ARM(Automatic Resource Management)模式:

def processEvent(row: Row): Unit = { var conn: java.sql.Connection = null var stmt: java.sql.PreparedStatement = null try { conn = ds.getConnection() stmt = conn.prepareStatement("INSERT ...") stmt.setString(1, row.getString(0)) stmt.execute() } catch { case e: SQLException => logError("DB write failed", e) throw e // 重新抛出,让Spark重试该partition } finally { if (stmt != null) try { stmt.close() } catch { case _: Throwable => } if (conn != null) try { conn.close() } catch { case _: Throwable => } } }

验证阶段我们做了三件事:

  1. 压力测试:用spark-sql-perf工具模拟1000个并发partition,持续运行2小时,numConnectionsAllUsers稳定在36±1
  2. 故障注入:在execute()前手动抛RuntimeException,观察c3p0是否在60秒内回收连接——日志显示DEBUG: com.mchange.v2.c3p0.impl.NewPooledConnection@... was forcibly destroyed due to unreturned timeout
  3. 监控看板:在Grafana中新增面板,监控c3p0.numConnectionsAllUsers和c3p0.numBusyConnectionsAllUsers的差值,设置告警阈值>5

修复后,该作业连续稳定运行37天,连接泄漏率为0。这印证了一个朴素真理:在分布式系统中,最可靠的容错不是框架提供的,而是开发者亲手写的finally块。

最后分享一个小技巧:在foreachPartition开头添加连接池健康检查,可提前发现配置问题:

val ds = C3P0DataSourceFactory.getDataSource(conf) if (ds.getNumConnectionsDefaultUser == 0) { throw new RuntimeException(s"c3p0 pool init failed for ${ds.getJdbcUrl}") }
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 19:54:12

diagram-design:用可执行图谱替代传统流程图的系统建模方法

1. 项目概述&#xff1a;这不是画图&#xff0c;是构建可执行的系统逻辑骨架 “diagram-design”这个词组乍看像一个普通的设计术语&#xff0c;但在我过去十年带过的几十个跨领域项目里&#xff0c;它从来不是PPT里那几张花哨的流程图。它是一套 把模糊想法变成可验证、可协作…

作者头像 李华
网站建设 2026/10/20 6:41:22

PPT自动化生成工具设计:数据与样式分离架构及实操指南

1. 从零拆解一个PPT自动化工具的设计思路 第一次看到"ppt-master"这个命名&#xff0c;我的直觉是&#xff1a;这大概率是一个围绕PPT批量生成、自动化排版或模板管理的工具类项目。在办公自动化这个赛道里&#xff0c;PPT一直是最难啃的骨头——Word和Excel早就有成…

作者头像 李华
网站建设 2026/10/22 14:15:13

算法竞赛进阶:字符串处理、动态规划与图论实战

1. 题目解析与解题思路作为一名经历过无数OJ题海战术的老手&#xff0c;我深知第13到15题往往是算法竞赛中的关键转折点。这三道题通常代表着从基础语法练习向算法思维训练的过渡&#xff0c;很多选手在这里会遇到第一个能力瓶颈。下面我将逐题拆解常见考点和破题技巧。1.1 第1…

作者头像 李华
网站建设 2026/10/20 19:22:22

基于日特征气象因素的SVR短期负荷预测实践

去年年中我接了一个活&#xff1a;给某区域电网做一个短期负荷预测模块。当时手头的数据很朴素——近三年逐日的气象观测记录、一张节假日历表&#xff0c;以及一整年的电网负荷采集值。团队里有人提议直接上深度模型&#xff0c;但我最后坚持用了支持向量机&#xff0c;把&quo…

作者头像 李华
网站建设 2026/10/22 9:26:47

Android图形显示系统架构与优化实践

1. Android图形显示系统全景透视 在移动设备的人机交互体验中&#xff0c;图形显示系统如同交响乐团的指挥&#xff0c;协调着从应用绘制到屏幕刷新的每个环节。Android系统通过精心设计的架构&#xff0c;将2D/3D图形渲染、界面合成与显示输出等复杂流程封装成对开发者友好的接…

作者头像 李华
网站建设 2026/10/22 8:34:39

C语言字符串处理:元音字母提取实战

1. 项目概述这个C语言练习项目源自经典的"菜鸟教程C经典100例"中的第49题。作为C语言学习路上的必经之路&#xff0c;这类练习能帮助初学者掌握指针、数组、字符串处理等核心概念。我在大学时期就曾反复练习过这些题目&#xff0c;现在回头看依然觉得它们设计得非常精…

作者头像 李华