分类目录归档:技术

如何使用Mysql正确的处理财务数据

财务数据相比于普通的互联网应用数据,对数据的一致性有更高的要求。因为涉及到用户金钱的流动,出现问题就意味金钱和声誉上的损失。在用 Mysql 处理财务数据时,我认为应该遵循以下原则:

  1. 使用 DECIMAL 数据类型存储金额。因为浮点数精度是有限的,并且无法精确的表示一些数字。应用程序也应该使用 Decimal 函数库来进行金额的加减乘除的运算,比如 Python 的 decimal 模块,C++ 的 boost Multiprecision 库。
  2. 使用事务来更新数据库。涉及到数据库多个记录更新时,事务能够做到要么全部成功,要么全部失败,这保证了数据的一致性。Mysql 使用事务需要 InnoDB 引擎。
  3. 更新数据库时使用悲观锁。更新数据前使用 SELECT …  FOR UPDATE; 来查询,这样防止并发的请求读到脏数据,导致数据错乱。虽然加锁会影响性能,但为了数据的一致性也是值得的。
  4. 记录资金变化的流水日志。不能简单的只记录用户的金额,还要记录每笔资金的来龙去脉,包括变化的大小、时间、业务、更新前金额、更新后金额、备注等,另外还有记录业务ID防止重复更新金额。这样在对账的时候才能有理有据。

下面以一个完整的示例来说明如何设计一个完备的记账系统用来记录人民币余额,这里精度只需要2位小数就可以了。

假设已经有一个用户表,每个用户有一个唯一ID。我们需要创建两张表,一张余额表,一张流水表:

CREATE TABLE `balance` (
  `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY,
  `user_id` int NOT NULL,
  `item` varchar(10) NOT NULL,
  `balance` decimal(20,2) NOT NULL
) ENGINE=InnoDB;

CREATE TABLE `history` (
  `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY,
  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `user_id` int NOT NULL,
  `item` varchar(10) NOT NULL,
  `amount` decimal(20,2) NOT NULL,
  `befor` decimal(20,2) NOT NULL,
  `after` decimal(20,2) NOT NULL,
  `business` varchar(30) NOT NULL,
  `business_id` varchar(100) NOT NULL,
  `detail` text
) ENGINE=InnoDB;

为了加快查询速度,另外为了有效利用 InnoDB 的行级锁,我们需要给两张表加上联合索引。另外,我们需要保证流水记录中 user_id, item, business, business_id 的组合是唯一的,避免重复更新数据。

ALTER TABLE balance ADD INDEX `user_item_idx` (`user_id`, `item`);

ALTER TABLE history ADD INDEX `user_item_idx` (`user_id`, `item`);

ALTER TABLE history ADD UNIQUE update_unique (user_id, item, business, business_id);

假设这时候用户 ID 为 1 的用户充值了 100 元人民币,我们需要把用户的人民币余额加上 100 元,我们要如何处理呢?

这里我们还需要一个充值表用来保存用户的充值记录,同时,我们再创建一个提现表来表示用户提现记录,如下所示:

CREATE TABLE `deposit` (
  `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY,
  `user_id` int NOT NULL,
  `item` varchar(10) NOT NULL,
  `amount` decimal(20,2) NOT NULL
) ENGINE=InnoDB;

CREATE TABLE `withdraw` (
  `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY,
  `user_id` int NOT NULL,
  `item` varchar(10) NOT NULL,
  `amount` decimal(20,2) NOT NULL
) ENGINE=InnoDB;

首先,开始一个事务,创建充值记录:

START TRANSACTION;

INSERT INTO deposit VALUES (NULL, 1, 'CNY', '100')

插入成功后,我们可以使用 MYSQL 的 API 获取到上次插入后生成的自增 ID 的值,假设这里也为 1. 然后我们需要从数据库查询当前余额。查询的时候注意要使用 FOR UPDATE 来锁住该记录,避免其它并发的请求读到脏数据。即使当前该记录不存在,在事务提交之前,其它读请求仍然被阻塞读不到数据的。

SELECT id, balance FROM balance where user_id = 1 and item = 'CNY' FOR UPDATE;

由于用户是新注册的,查询到的数据为空,所以我们创建新的记录:

INSERT INTO balance VALUES (NULL, 1, 'CNY', '100');

然后我们要在流水表中记录下这次的变更操作:

INSERT INTO history VALUES (NULL, NULL, 1, 'CNY', '100', '0', '100', 'deposit', '1', '');

最后提交事务:

COMMIT;

事务提交后,所有的数据都写入到数据库中了,中间如果有异常发生,则执行

ROLLBACK;

来放弃所有的变更。

假设过了一段时间用户要求提现 50 元,操作流程如下:

START TRANSACTION;

SELECT id, balance FROM balance where user_id = 1 and item = 'CNY' FOR UPDATE;

假设读出的 id 也为 1. 由于用户之前充值了 100 元还没有使用,所以余额是 100,大于 50 满足提现条件。如果不满足的话需要执行 ROLLBACK. 然后:

INSERT INTO withdraw VALUES (NULL, 1, 'CNY', '50');

UPDATE balance set balance = '50' WHERE id = 1;

INSERT INTO history VALUES (NULL, NULL, 1, 'CNY', '-50', '100', '50', 'withdraw', '1', '');

COMMIT;

这就完整的实现了整个充值和提现的流程。

当然,这里示例的充值和提现是最简化的流程,实际业务中,充值和提现往往涉及到多种状态的流转。并且在提现中,用户发起提现和实际进行转账不可能是同时进行的,中间可能会取消操作,直接扣除资金不太妥当,更妥当的做法是把待提现的资金冻结起来,冻结操作其实就是创建一个新的 item: CNY_FREEZE, 在 CNY 上扣减,然后在 CNY_FREEZE 上增加来实现。转账后再从 CNY_FREEZE 中扣除。如果取消操作则取消冻结,从 CNY_FREEZE 扣减,增加到 CNY 上面,这样整个流程在流水日志上都有体现。

基于Mysql的流水日志记录系统

什么是流水日志?说白了,就是用户的操作日志,包括 who, when, what, how, why 等等。以一个论坛的发表流水日志为例,可能需要记录发表者的帐号ID,发表时间,发表IP,发表内容,发表类型,所在的主贴ID等等。有了这些数据,事后很容易进行分析和统计。

流水日志的重要性是不言而喻的。再我负责的工作中,有很多种类的流水日志需要记录,以供日后的查询和建模分析,并且这些流水日志要记录的内容也经常需要扩展。我们一般使用 Mysql 来记录这些数据。

记录流水日志看似是一件很简单地事情,直接写入数据库就可以了。但当数据量大起来之后,在几千条每秒的量级下,很多原来不是问题的问题,就变成了问题。

首先业务程序直接写数据库是行不通的,就需要搭建专门的流水日志记录服务,通过网络进行通信。这个服务也不能直接写数据库,要有专门的进程(或线程)负责处理请求,另外有进程(或线程)负责写入数据库。或者简单一点的话,先写入文件,然后再定时的写入数据库,但这样会有延迟。每次一条的进行数据库插入速度会很慢,可以通过批量插入或Load in file的方式,以提高写入的效率。

其次,一旦涉及到每天几亿条数据的量级,对于 Mysql 来说,全部放在一个表里也是行不通的,这就涉及到分表的操作。最简单的,按自然时间分表,比如每天的数据写一张表里。分得太细的不方便查询,分的太粗数据量太大,查询也慢。另外,在按时间切分数据表的基础上,有时还需要按照某个具体字段再次分表,以减少单个表的数据量,提高查询效率。比如按用户ID取模,每天再分100张表。

另外,如果需要对字段进行扩展,也是一个麻烦事,如果系统设计的不好,很可能会在升级的过程中丢失部分数据。

一开始,我们是每个系统自己实现流水日志的记录,当然也就没做的很好,出过各种问题。并且,每次做一个新系统,都要重复解决上面的问题,很繁琐。

为了简化这个过程,我设计并实现了Logdb, 它提供一个基于Mysql的通用可配置化的流水日志记录系统。对于业务来说,只需要配置一下,然后调用自动生成的API就好了。会根据配置自动进行分表,如果是 MyISAM 引擎,会同时创建Merge表。并且支持自动删除过期不再需要的数据。扩展也很简单,能够自动识别新增的字段并且修改数据库,并且API是向后兼容的。代码已经开源,再这里,有兴趣的同学可以看一下。

Logdb使用UDP协议通讯,每次调用API就相当于向指定地址发送一个UDP请求。默认情况下Logdb是没有回包的。API也不会等待回包,这样调用API的开销是很小的,几乎不会对业务进程造成任何的延迟。UDP协议是不可靠的,不过在局域网内,UDP发生丢包的概率还是极低的。并且UDP在实现上相对比较简单,后续可能会添加对TCP协议的支持。

Logdb在实现上分为了3层,分别称为 Interface, Reciver 和 Worker,整体架构可以用下面这个简图表示(Windows 自带画图画的,比较挫)。

Logdb架构

其中最核心的部分是 Reciver 进程。Reciver 在收到请求后,根据配置和协议格式对消息进行解析,并生成对应的SQL语句。在默认情况下,为了提高入库的效率,不是每条日志都进行入库,而是缓存一定的时间,进行批量入库。因为对于 Mysql 来说,使用含多个VALUE的INSERT语句同时插入几行,比单行的 Insert 要快很多倍。在默认情况下,LogDB对每个表维护一个缓存,如果缓存时间超过 500ms,或者缓存长度达到 1MB,则进行入库操作。这对于人来说,可以说是实时入库的,查询上不会造成延迟。

因为入库是一个阻塞的操作,所以将入库的工作交给另一个独立的进程Worker 来做。Reciver 通过一个基于共享内存和文件的队列将 SQL 语句推送给一个 Worker。这个队列也是很有趣的:当Worker 没有即时处理队列中的数据导致队列内存满时,会自动把新的数据写入文件。这就保证即使在短时间内请求量超过Worker 进程能处理的量时,数据仍然不会丢失,而在正常情况下,又能避免大量的文件IO操作。

Worker 在入库时,如果检测到因为数据库连接断开而失败的情况,则把失败的数据放入另一个队列。当数据库连接恢复时,自动的重新入库这些数据。这就保证,即使在数据库发生异常或重启时,也不需要人为的干涉,更不会丢失数据。

那 Interface 进程做什么呢?其实 Interface 进程所做的工作非常简单:它将接受到的请求转发给 Reciver并等待回包。如果超过1s 没有收到 Reciver 的回包,那么同样把这个请求放入一个队列,等到 Reciver 有回包时重试。这就保证,即使在重启 Reciver时,新的数据也不会丢失。

Logdb 的配置使用 ini 格式,还是很简单易懂的,自带的配置文件是自说明的,在这里

扩展新的字段时,需要再原有字段的最后面添加。这是因为,Logdb 所用的协议是非常简单的:首先是简单地包头,然后是数据部分,数据是按照配置的简单地顺序排列再一起的。如果新增的字段放在了中间,那么在解析请求时就可能得到脏数据或者解析失败。为了在升级后兼容老的API,再解析请求时添加了简单地规则:如果请求包剩余长度为0,那么使用空值。

一开始是准备设计的更通用一点,希望能够支持多种数据库,但后来发现不同的数据库支持的字段差别还是挺大的,比较难实现的通用。我们用 Mysql 比较多,就只支持这一种好了。

读了点 redis 的源码

很早之前边对 redis 有所耳闻,但了解不多,直到前不久看到《V2EX 从过去一年半中学到的几件事》这篇文章。文中提到 redis 可以替换掉 Mysql 而单独使用,吃惊了不少,准备下决心研究一下 redis. 加上前不久看过部分 twemcache (twemcache 是 twitter 的 memcache 改写版,以下简称 memcache)的源码,所以这里结合这两者分享一下心得。

redis 是一种纯内存的、key-value 的数据库,NO-SQL 的一种。它的 value 支持 string(字符串)、list(链表)、set(集合)、zset(sorted set –有序集合)和hashs(哈希类型)。redis  还支持持久化和主从复制。

相比之下,memcache 就纯粹多了。memcache 是一种纯内存的 key-value 缓存系统,value 类型只是一段 buffer,不支持持久化,不支持主从复制。

redis 和 memcache 都是使用 ANSI C 实现的,代码质量和可读性很高,非常有学习价值。

redis 中的数据结构

我读 redis 源码时主要读的是 redis 内部使用的一些基本数据结构。这些数据结构都是非常通用的。

sds:Simple Dynamic String,简单动态字符串。是 Redis 底层所使用的字符串表示, 它被用在几乎所有的 Redis 模块中。它很简单,就是一个带长度的、能自动扩展的、基于堆内存的字符串,并且兼容 C 中的字符串。

adlist: 双向链表,链表这东西已经被无数程序员实现过无数次了,无需多讲。

dict: 字典,有趣的是它扩容的过程。每个字典内部有两个 hash 表,一般用第一个,当发现 hash 表容量太大需要扩容时,创建第二个 hash 表。这时候不能一下子把第一个 hash 表里面的数据全部导入到第二个 hash 表,因为这样可能会导致阻塞。而是在每次执行查找或添加等操作时,从第一个 hash 表迁移一个节点到第二个 hash 表,这样像愚公移山一样直到扩容完毕。

skiplist: 跳跃表,这个数据结构也很有意思它,它是一种有序链表,加上随机化数据结构,基于并联的链表,其效率可比拟于二叉查找树(对于大多数操作需要O(log n)平均时间)。它的原理可以查看这篇文章 http://dsqiu.iteye.com/blog/1705530

ziplist: 一种压缩链表 ,使用压缩型的数据结构,减去指针的消耗,能够显著的减小对内存的使用。list, map, zset 在数据量小于特定值时,都是使用的 ziplist 以降低内存的消耗。

另外还有 intset, zipmap 等数据结构,不做多述。

 redis 的主从复制

redis 的 master, slaver 实现的可以说是相当优雅,浑然天成。关于主从复制与同步的问题,之前在工作中也遇到过,但苦于没找到一个更好的解决方案。主从结构的同步需要一次全量同步和后续的增量同步,同步的关键在于顺序,比如先 del 一个 key, 然后 add 这个 key, 如果同步成先 add 然后 del 的话,数据就不一致了。redis 的做法是,在收到同步请求之后,利用其持久化的能力,生成一个 RDB 内存 dump 文件,在生成 RDB 文件的同时,维护一个从这时候开始所有所有写操作的链表。RDB 文件生成好后,发送给 slaver, 然后再把生成 RDB 文件时所有的写操作命令发送给 slaver 执行 ,这样 slaver 就完成了一次全量更新。接下来 master 每次执行写操作时都会同步给 slaver。

redis 与 memcache

关于内存分配:memcache 的主要卖点便是其内存的分配。memcache 实现自己的内存分配是因为 glibc 自带的 malloc/free 性能太差,并且容易出现内存碎片。memcache 通过被称为 slab 的内存分配机制对内存进行管理。而 redis 没有自己分配内存,而是使用了第三方的内存分配器。redis 源码自带了 jemalloc, 也可以使用 google 的 tcmalloc, 这两者性能都很高,并且几乎 不会出现内存碎片问题。

关于事件处理:memcache 使用了开源的 libevent  进行事件的处理,包括网络IO和定时器。而 redis 则根据其不依赖的原则,实现了自己的事件处理库,对 epoll 等函数进行了封装。

关于配置:memcache 使用 getopt 进行命令行解析,只支持命令行参数形式配置,不支持配置文件。而 redis 同时支持配置文件和命令行。redis 解析命令行时,没有使用 getopt 等标准化命令行解析工具,而是自己通过 strcmp 进行解析,一开始觉得这个做法相当丑陋,后来发现自己误解了 redis 作者的苦衷了。redis 把除了 –help –version 等基本参数之外的其他 — 开头的参数,去除前面的 –,加入到一个字符串中。如果有配置文件,也把这个文件的内容一并读进这个字符串,然后由统一的函数对这个字符串进行配置解析。这样,就统一了配置文件和命令行参数,只要配置文件中有的,都可以通过命令行进行配置,个人觉得非常巧妙。

关于线程:memcache 是多线程的,由一个主线程处理连接,调度其他线程处理请求,充分利用多核 CPU 的能力。而 redis 是单进程的。严格说来,redis 是线程和进程混合的,它使用线程来进行 fsync ,也使用多进程利用 fork 的 copy-on-write 特性进实现 RDB 的持久化。但redis 在处理请求时,是单线程的。为什么不使用多线程,一个是因为对于处理内存数据,单线程已经够快了,另外,单线程能够提供一定的事务特征。为了充分利用资源可以在单台机器上起多个 redis 实例。

redis 的性能

redis 自带了基准测试工具 redis-benchmark 测试常用命令的性能,根据测试,一般的 GET SET 命令单 CLIENT 阻塞调用时,都可以达到上万次每秒,可以说大部分场合已经足够使用了。

参考:

redis 官方文档

Redis 设计与实现

Mysql 中的 utf8

最近遇到了一个奇怪的问题,在向  Mysql 插入一个 UTF-8 字符串时,字符串被截断了。打印 执行的 SQL 发现,是断在了一个无法显示的字符上。一开始怀疑是非法 UTF-8 编码,但通过 hexdump 发现,它是一个合法的 utf-8 字符,编码长度是 4 个字节。然后将其解码为 Unicode 编码,google 了一下,原来是一个 Emoji 表情(Emoji 是一种特殊的 Unicode 编码,常见于 ios 和 android 手机上)。

为什么遇到 Emoji 表情会被截断呢,在我的理解中,Mysql 的 utf8 应该可以接受任何合法的 UTF-8 字符串的。经同事提醒,Mysql 早期版本只支持最长 3 个字节的 UTF-8。查了下文档,果然如此:

  • utf8, a UTF-8 encoding of the Unicode character set using one to three bytes per character.

不只是老版本的 Mysql,最新版本的 Mysql 仍是如此。三个字节的 UTF-8 最大能编码的 Unicode 字符是 0xffff,也就是 Unicode 中的基本多文种平面(BMP)。也就是说,任何不在基本多文本平面的 Unicode字符,都无法使用 Mysql 的 utf8 字符集存储。包括上述的 Emoji 表情,和很多不常用的汉字,以及任何新增的 Unicode 字符等等。

此 utf8 非彼 UTF-8.

UTF-8 是 Unicode 的一种传输编码格式(8-bit Unicode Transformation Format)。最初的 UTF-8 格式使用一至六个字节,最大能编码 31 位字符。最新的 UTF-8 规范只使用一到四个字节,最大能编码21位,正好能够表示所有的 17个 Unicode 平面。

utf8 是 Mysql 中的一种字符集,只支持最长三个字节的 UTF-8字符,也就是 Unicode 中的基本多文本平面。

Mysql 中的 utf8 为什么只支持持最长三个字节的 UTF-8字符呢?我想了一下,可能是因为 Mysql 刚开始开发那会,Unicode 还没有辅助平面这一说呢。那时候,Unicode 委员会还做着 “65535 个字符足够全世界用了”的美梦。Mysql 中的字符串长度算的是字符数而非字节数,对于 CHAR 数据类型来说,需要为字符串保留足够的长。当使用 utf8 字符集时,需要保留的长度就是 utf8 最长字符长度乘以字符串长度,所以这里理所当然的限制了 utf8 最大长度为 3,比如 CHAR(100)  Mysql 会保留 300字节长度。至于后续的版本为什么不对 4 字节长度的 UTF-8 字符提供支持,我想一个是为了向后兼容性的考虑,还有就是基本多文种平面之外的字符确实很少用到。

要在 Mysql 中保存 4 字节长度的 UTF-8 字符,需要使用 utf8mb4 字符集,但只有 5.5 版本以后的才支持。我觉得,为了获取更好的兼容性,应该总是使用 utf8mb4 而非 utf8.  对于 CHAR 类型数据,utf8mb4 会多消耗一些空间,根据 Mysql 官方建议,使用 VARCHAR  替代 CHAR。

C 语言中的变长数组

或许这是很多 C 程序员都不知到的特性:C 语言中可以定义变长数组。也就是说,定义数组时,数组的长度可以是变量,而不一定非要是常量。比如:

    int len = 100;
    char str[len];

这个特性无疑是非常有用的。比如在不知到数组的长度时,要么定义一个足够大的数组,要么使用内存分配函数如 malloc 函数获取适当大小的内存,但需要记得 free. 使用变长数组,使得这个过程变得优雅和简化。

这个特性是在 C99 中加入 C 的标准中的,事实上,GNU C 在之前已经支持这种写法了。vc 对 C99 的支持一直不怎么热心,所以 VC 中并不支持这种写法。所以如果代码又跨平台的需求,最好不要使用变长数组,并且最好只使用 C89 的特征。

需要注意的是,变长数组只能是局部变量,不能是静态变量和全局变量,因为这两者的长度是编译时决定的,而变长数组的长度要到运行时才能确定。变长数组是局部变量,所以是有生命周期的,其生命周期仅在当前域内,即 {} 内。

变长数组使用的内存是栈内存,所以需要注意数组长度不能太大超过栈内存大小限制。linux 上可以用 ulimit -s 查看栈大小,一般为 8M.

停止使用 strncpy 函数!

也许你曾经被多次告知,要使用 strncpy 替代 strcpy 函数,因为 strncpy 函数更安全。而今天我要告诉你,strncpy 是不安全的,并且是低效的,strncpy 的存在是由于一个历史原因造成的,你不应当再使用 strncpy 函数。

下面我来解释为什么 strncpy 函数是不安全并且是低效的,以及我们应该使用那些替代函数。

我以前对 strncpy 一直存在误解,直到有一次出现了 BUG。好不容易定位到 strncpy 身上,然后仔细查看文档,才明白问题所在。

误解一:如果 src 长度小于 n, 那么strncpy 和 strcpy 效果一样?

错,事实上,strncpy 还会把 dest 剩下的部分全部置为 0!

一直认为 strncpy 只是比 strcpy 多了长度校验,确不知道 strncpy 会把剩下的部分全置为 0(粗体部分)。

char *strncpy(char *dest, const char *src, size_t n);

DESCRIPTION
The strcpy() function copies the string pointed to by src (including the terminating `\0′ character) to the array pointed to by dest. The strings may
not overlap, and the destination string dest must be large enough to receive the copy.
The strncpy() function is similar, except that not more than n bytes of src are copied. Thus, if there is no null byte among the first n bytes of src,
the result will not be null-terminated.
In the case where the length of src is less than that of n, the remainder of dest will be padded with null bytes.

这会导致什么后果呢?

首先,如果 strncpy 的长度填错了,比如比实际的长,那么就可能会把其他数据清 0 了。我就遇到过这个问题,在后来检查代码看到这个问题时,也并不以为然,因为拷贝的字符串不可能超过缓冲区的长度。

另外,假设 dest 的长度为 1024, 而待拷贝的字符串长度只有 24,strncpy 会把余下的 1000 各字节全部置为 0. 这就可能会导致性能问题,这也是我为什么说 strncpy 是低效的。

误解二:如果src 长度大于等于 n, 那么 strncpy 会拷贝 n – 1 各字符到 dest, 然后补 0?

错,大错特错,罚抄上面的 DESCRIPTION ,直到看到:

if there is no null byte among the first n bytes of src, the result will not be null-terminated.

这就可能导致了不安全的因素。

如果待拷贝字符串长度大于了 n, 那么 dest 是不会有结尾字符 0 的。假设这样一种情况:

    char s[] = "hello world";
    strncpy(s, "shit!", 5);
    puts(s);

输出的结果是 “shit” 还是 “shit! world” ?

这种情况只是导致了输出结果错误,严重的,如果 dest n 字节后面一直没有 0,那么就会导致程序段错误。

strncpy 最开始引入标准库是用来处理结构体中固定长度的字符串,比如路径名,而这些字符串的用法不同于 C 中带结尾字 0 的字符串。所以 strncpy 的初衷并不是一个安全的 strcpy.

那么用那些函数来替代 strncpy?

1、使用 snprintf

snprintf(dest, n, src);

的效果和我们对一个安全的字符串拷贝函数的期望完全一致。

但是这个函数效率有点问题,并且特殊字符比如 %d 会转义。

2、自己实现一个高效并且安全的字符串拷贝函数 sstrncpy,开头的 s 代表 safe

/* safe strncpy */

char *sstrncpy(char *dest, const char *src, size_t n)
{
    if (n == 0)
        return dest;

    dest[0] = 0;

    return strncat(dest, src, n - 1);
}

使用 strncat 是因为很难实现一个性能能够达到库函数的字符串拷贝函数。

3、但是,上面两个函数都有一个问题:如果不能预知 src 的最大长度,那么 src 会被静默的截断。

如果是为了复制一个字符串,那么更好的做法是使用 strdup 函数

char * strdup (const char *s);

strdup 函数会调用 malloc 分配足够长度的内存并返回。

当然,你需要在你不使用的时候 free 它。

如果只是函数内部调用,也可以使用 strdupa 函数。

char * strdupa (const char *s);

strdupa 函数调用 alloca函数而非 malloc 函数分配内存,alloca 分配的内存是桟内存而非堆内存。所以当函数返回后,内存就自动释放了,不需要 free。

4、如果是从文本文件中读数据,相对与 fgets 函数,更好的做法是使用 getline

ssize_t getline (char **lineptr, size_t *n, FILE *stream);

一个简单的例子:

char *line = NULL;
size_t len = 0;

while (getline(&line, &len, stdin) != -1)
{
    fputs(line, stdout);
}

free(line);

当 line 为 NULL 或者 len 为 0 时,getline 调用 malloc 分配足够大的内存。所以你需要在用完后 free 它们。

和 fgets 相同,getline 得到的行是带换行字符的。

所以,忘了 strncpy 吧,血的教训,说出来都是泪…