很多朋友一开始接触 SQL Server 数据类型的时候,都觉得这有什么好学的?不就是 int、varchar、datetime 那几个吗?我早前也是这样想的,直到有一天帮同事排查一个报表对账差异:订单表的金额字段当年图省事用了 float,跑一段时间后,各种汇总结果总是差那么几分钱。查了一个下午,最后定位到是浮点精度丢失。改字段类型,要动存储过程、动历史数据、动前端展示,牵一发动全身。那次之后我才意识到,SQL Server 数据类型不是"建表时随手一选"的事,它直接决定了后续的存储成本、查询性能、精度边界,甚至能不能正确支撑业务逻辑。所以这篇就把 SQL Server 里的所有数据类型系统地过一遍,适合正在建表选型、准备优化库结构、或者想系统补一遍基础的人。
1. 数值类型的账单:int、decimal 和 float 真的值那么多字节吗
1.1 整数类型:从 bit 到 bigint 的取舍
整数类型大家最熟,但真到建表的时候,很多人直接无脑上 int。int 当然是万金油,但不同类型的存储成本和取值范围差别很大,尤其在几千万行的大表里,一个字段省 2 个字节,整个表可能就省出几个 GB。
先说最小的 bit,它只占 1 位,实际存储上每 8 个 bit 组成一个字节。它只能存 0、1 或者 NULL,适合表达"是/否""启用/停用"这类布尔语义。注意 bit 不是算数类型,别拿它做加减乘除,虽然 0 和 1 能参与一部分计算,但会绕远路。
然后是 tinyint,1 字节,范围 0 到 255。适合存年龄、小范围的状态码、枚举值。smallint 是 2 字节,范围 -32768 到 32767,像端口号、数量较少的库存这类字段。int 是 4 字节,范围 -2147483648 到 2147483647,大部分业务主键和计数器用它都绰绰有余。bigint 是 8 字节,最大到 9223372036854775807,一般只有雪花 ID、流水号、大数量统计这类场景才轮得到。
这里有一个常常被忽略的细节:SQL Server 的整数类型是有符号的,所以设计自增主键时,int 的上限 21 亿看着很多,可如果是日志、流水表这种每年几亿条写入的场景,几年就到顶了。与其到时候做 int 到 bigint 的迁移,不如一开始就评估写入量。我自己处理过一个用户行为日志表,三年写了 6 亿行,主键 int 差点爆掉,当时做大版本迁移,痛苦指数极高。所以流水类、日志类表,主键直接 bigint 是更稳妥的做法。
| 类型 | 存储大小 | 取值范围 | 典型场景 |
|---|---|---|---|
| bit | 1 位 | 0 / 1 / NULL | 布尔标志位 |
| tinyint | 1 字节 | 0 ~ 255 | 状态码、小枚举 |
| smallint | 2 字节 | -32768 ~ 32767 | 年龄段、小数值 |
| int | 4 字节 | -2147483648 ~ 2147483647 | 常规主键、计数 |
| bigint | 8 字节 | -9223372036854775808 ~ 9223372036854775807 | 大流水、雪花 ID |
1.2 decimal/numeric 的精度、小数位和存储成本
decimal 和 numeric 在 SQL Server 里完全等价,没有任何区别,只是名字不同。很多人会问:我存金额用 decimal(18,2),这个 18 和 2 是怎么定的?18 是总有效位数,2 是小数位数,也就是说整数部分是 16 位。为什么业界默认 decimal(18,2)?因为绝大多数业务的订单金额都不会超过 9999999999999999.99,也就是千万亿量级,足够用;而在最大精度 38 位以内,这个精度组合的存储成本是 9 字节,如果放宽到 decimal(38,2),会涨到 17 字节。
decimal 精度和存储字节的对应关系是这样的:
| 精度范围 | 存储大小 |
|---|---|
| 1 到 9 位 | 5 字节 |
| 10 到 19 位 | 9 字节 |
| 20 到 28 位 | 13 字节 |
| 29 到 38 位 | 17 字节 |
所以设计时要克制,不是精度越高越好。比如一个零售订单金额,decimal(18,2) 完全够,但如果用了 decimal(38,2),单字段就多占 8 字节。别小看这 8 字节,一亿行的表就是 800MB,还不算索引的开销。
另一个常见坑是除法运算。decimal 除以 decimal 时,SQL Server 会按特定规则自动放大结果的小数位,可能让中间结果超出目标精度。写过报表的都有经验:两个 decimal(18,2) 相除,结果有时候会莫名其妙变成 18 位小数,再四舍五入回 2 位反而误差更大。所以涉及除法的中间字段,建议先估算结果精度,必要时先把分子分母转换成更高精度,最后再统一脱俗到目标小数位。
1.3 float、real 与 money:三个容易误用的类型
float 和 real 属于近似数值类型。real 是 float(24),占 4 字节;float 默认占 8 字节。它们存储的是二进制近似值,不是精确小数。0.1 在二进制里是一个无限循环小数,所以 float 类型做加减乘除,出现 0.1+0.2 不等于 0.3 这种事太正常了。这就是我开头说的那个对账 bug 的根源。
所以有一条铁律:凡是涉及金额、余额、税率这类需要精确计算的字段,绝对不用 float 和 real。那么 float 用来做什么?科学计算、测量值、百分比估算、GPS 坐标这类对精度不敏感、但需要大范围数值的场景。需要注意的是,如果两个 float 在 WHERE 条件里用等号比较,很可能因为微小误差查不到数据,正确做法一般是"ABS(a - b) < 0.0001"这类范围判断。
money 和 smallmoney 是 SQL Server 特有类型,专门表示货币。money 占 8 字节,范围 -922337203685477.5808 到 922337203685477.5807,小数位固定 4 位。之所以很多人不用它,是因为单位换算场景下容易踩坑,而且和小数运算混用时规则不直观。但如果你确定字段就是人民币金额、不做复杂汇率换算,money 是一个省空间的方案。不过说实话,现代项目里我更推荐 decimal(18,2),因为 ORM 框架、报表工具对 decimal 的支持更一致,团队的认知成本也更低。
2. 字符串类型:定长变长与排序规则,比想象中复杂
2.1 char 和 varchar:定长与变长的本质区别
char(n) 是定长字符串,n 最大 8000。你存一个字符进去,它也会占 n 个字符的空间,不足部分用空格补齐。varchar(n) 是变长,n 同样最大 8000,实际存储按内容长度来,额外多 2 字节记录长度。所以在"几乎都是固定长度"的场景,char 会有微弱的性能优势,比如身份证号、手机号、MD5 摘要这类固定长度的字段用 char 是合理的;但绝大多数业务字段长度都不固定,无脑用 varchar 就好。
varchar(max) 则是把上限扩展到 2GB 存储空间。注意 max 和普通 n 之间有一条性能分水岭:<= 8000 字节的行内存储,读写走普通行的页结构,性能稳定;一旦超过 8000 字节,或者使用 varchar(max),SQL Server 可能将其转入大对象存储(LOB),读写方式完全不同。如果你只是存一段几百字的备注,用 varchar(500) 绰绰有余,没必要上 max。很多人建表图省事,所有字符串全写 varchar(max),最后表里全是 LOB,查询性能下降,备份体积暴涨,重建索引也更慢。
还有一个细节:varchar 在默认排序规则下,一个字符占 1 字节。但如果你把数据库排序规则设置成带 UTF-8 的(比如 SQL Server 2019 起的 *_UTF8 规则),varchar 也可以存储 UTF-8 编码的 Unicode,这时某些字符会占 2 到 4 字节。这也是新版 SQL Server 一个很重要的方向,但默认情况下别指望它,旧库迁移到 UTF-8 排序规则前,一定要先做全库字符兼容性评估。
2.2 nchar 和 nvarchar:Unicode 的取舍
nchar(n) 和 nvarchar(n) 是 Unicode 字符串类型,内部按 UTF-16 编码存储。普通字符(包括中文、日文、韩文)大多占 2 字节,所以 nvarchar 的 n 最大只能到 4000。如果你的命名带 emoji 或者其他补充平面字符,一个字符会占 4 字节,4000 的上限也要相应折算。
很多初学者会问:既然 nvarchar 能存中文,varchar 也能存中文(因为中文在中文代码页里能表示),为什么还要用 nvarchar?关键差异在不同代码页之间。varchar 能否正确存储中文,取决于数据库排序规则对应的代码页;一旦换服务器、换排序规则环境,或者对接的客户端代码页不一致,就可能出现乱码。而 nvarchar 是标准 Unicode,跨系统、跨平台表现稳定。这也是为什么我建议默认字符串类型用 nvarchar。
代价当然也有:nvarchar 存储空间普遍是 varchar 的两倍左右。对纯英文标签、编码、固定标识这类字段,用 varchar 更经济。还有个经典误区是"nvarchar(n) 最多存 n 个汉字",这句话不全对,它最多存 n 个 UTF-16 码元,常规汉字没问题,但 emoji 这类字符会占用两个码元位置,实际可存数量要打折扣。
2.3 text、ntext:退出历史舞台的旧类型
text、ntext、image 是 SQL Server 2005 之前的老类型,用来存大段文本和二进制。现在的文档明确把它们标记为"已弃用",虽然某些老系统还在用,但新项目千万不要碰。它们不支持很多现代功能,比如不能直接用字符串函数、不能参与某些索引、排序和比较规则也不统一。需要用大对象时,直接上 varchar(max)、nvarchar(max)、varbinary(max),这是官方推荐的方向。
如果你维护的老库里还有 text/ntext 字段,迁移时要注意:直接 ALTER TABLE 改列为 varchar(max) 通常可行,但 text 类型有个特性是"文本指针"机制,历史遗留数据可能在页外,改完类型后需要重建表或重建聚集索引,才能真正回收空间。所以迁移后务必检查表大小有没有降下来,别只改类型不看存储。
3. 日期时间类型:六种容器,配错一种就够痛
3.1 date、time、datetime、smalldatetime、datetime2、datetimeoffset 对照
SQL Server 的日期时间类型有好几种,每个都对应不同场景,很多老项目从 2008 年以前延续下来,一直用 datetime,其实不一定是最优解。先看这张对照表:
| 类型 | 存储 | 日期范围 | 精度 | 说明 |
|---|---|---|---|---|
| date | 3 字节 | 0001-01-01 ~ 9999-12-31 | 1 天 | 只存日期 |
| time | 3 ~ 5 字节 | 00:00:00.0000000 ~ 23:59:59.9999999 | 100 纳秒 | 只存时间 |
| smalldatetime | 4 字节 | 1900-01-01 ~ 2079-06-06 | 1 分钟 | 秒数四舍五入到分钟 |
| datetime | 8 字节 | 1753-01-01 ~ 9999-12-31 | 3.33 毫秒 | 老项目的常驻类型 |
| datetime2 | 6 ~ 8 字节 | 0001-01-01 ~ 9999-12-31 | 100 纳秒 | 推荐的新标准 |
| datetimeoffset | 8 ~ 10 字节 | 0001-01-01 ~ 9999-12-31 | 100 纳秒 | 带时区偏移 |
看到区别了吗?datetime 的精度只有 3.33 毫秒,也就是秒后面只能到 .000、.003、.007 这样的间隔,并不适合需要毫秒级时间戳的场景。而 datetime2 支持到 100 纳秒,且存储占用反而可能更小——datetime2(0)(精确到秒)只要 6 字节,datetime 却要 8 字节。所以新项目我建议一律用 datetime2,精度按需指定。只需要日期就 date,只需要时间就 time,量级一目了然。
smalldatetime 是很多人容易忽略的陷阱:它的"秒"是近似值,会把秒数四舍五入到分钟。如果业务里有"记录事件发生的精确秒"需求,用它就会悄悄丢掉秒数据。
3.2 时区、精度和前后端约定的实际问题
datetimeoffset 是带时区偏移的类型,偏移范围 -14:00 到 +14:00。它的好处是能保留原始时区信息,适合做全球多时区应用。但要注意,SQL Server 并不会自动帮你做时区换算,存进去什么偏移就是什么偏移。如果你要统一存 UTC,还是自己在前端或者应用层先把时间转换成 UTC,再用 datetime2 或者 datetimeoffset 存储,显示时再转目标时区。
另外一个高频问题是精度和前后端语言的匹配。比如 .NET 里的 DateTime 精度是 100 纳秒级别,跟 datetime2 对应得很好,但如果数据库用的是 datetime,从 3.33 毫秒的精度往 100 纳秒转,应用层读到的值会有截断;反过来,JSON 序列化时如果按毫秒时间戳输出,前端看到的时间又可能比数据库少了几个毫秒。这些细节不处理,时间字段对不上,排查的时候非常抓狂。
还要提醒一个很常见的"今天"查询。以前很多人这么写:
WHERE CreateTime >= '2024-01-01 00:00:00' AND CreateTime < '2024-01-02 00:00:00'用 date 类型之后,可以直接写WHERE CAST(CreateTime AS date) = '2024-01-01',但要注意 CAST 包裹了列,如果 CreateTime 上有索引,这样写会导致索引失效。更好的做法是保持范围查询,再配合 date 类型的列或者计算列索引。
4. 二进制、GUID、XML 和那些"冷门"类型
4.1 binary 和 varbinary:存哈希、令牌这类定长二进制很合适
binary(n) 是定长二进制,varbinary(n) 是变长,varbinary(max) 最大 2GB,image 已弃用,全部用 varbinary(max) 替代。很多人印象里 varbinary 就是存图片、文件,其实当前架构下,我更推荐把文件放对象存储或文件系统,数据库只存引用路径,因为二进制大对象会让数据库备份体量失控、内存池压力变大,读写吞吐也难上去。
varbinary 最实用的场景其实是存哈希摘要。比如用 HASHBYTES 算出的 SHA2_256 哈希是 32 字节定长,这时候用 binary(32) 存储,比转成字符串的 varchar(64) 省一半空间,而且排序、等值比较都快很多。令牌、指纹、加密盐这类定长二进制数据同理。
4.2 uniqueidentifier 做主键的代价与对策
uniqueidentifier 就是 GUID,占 16 字节。很多分布式系统喜欢用它做主键,因为可以在应用层生成、不依赖数据库自增、合并数据时也不会撞主键。但它的代价非常明显:
第一,体积大。一个 GUID 是 16 字节,自增 bigint 才 8 字节,如果用 GUID 做聚集索引,那所有二级索引都会复制一份这个键值,索引体积翻倍。
第二,随机性导致页分裂。NEWID() 生成的 GUID 毫无顺序,插入时会让聚集索引频繁把数据页拆开,产生页碎片,写入性能随数据量增长明显下降。
如果一定要用 GUID 主键,有两条路:一条是用 NEWSEQUENTIALID() 生成顺序 GUID,能大幅缓解页分裂;另一条是 GUID 做主键的逻辑键,但聚集索引单独用一个自增 bigint 列,这样表内不会雪崩式碎片化,但会多一个索引列和额外的查找开销。到底选哪条,取决于你的插入量和对查询性能的敏感度。从我个人经验来看,绝大多数单体业务系统的自增 bigint 主键完全够用,不要为了"看起来很分布式"而主动引入 GUID 的复杂度。
4.3 rowversion、xml、sql_variant、空间类型与 hierarchyid
rowversion(旧名叫 timestamp)是 8 字节的自动递增二进制值,每行一更新就会自动变。它最适合做并发控制,也就是乐观锁——更新时带上上次读到的 rowversion,如果更新时发现版本变了,说明这行被改过,冲突可以拦截下来。注意它不能当真正的时间戳使用,和时间没有任何关系。
xml 类型可以存 XML 文档,最多 2GB,支持 XQuery 查询。不过在我看来,关系型数据库里存 XML 属于"能用但不优雅"的方案,通常意味着这一段数据的结构不够稳定或查询需求太弱。如果不是明确的配置类 XML、文档类数据,我更建议解析成关系表,或者直接用 JSON 字符串存储,让应用层处理。
sql_variant 是可以装下各种数据类型的"万能容器",理论上有用,实际使用场景非常有限,因为它不能参与很多操作,也不能存 text、ntext、image、rowversion、xml,排序比较还经常出问题。我建表时坚决不会用它。
geometry 和 geography 是空间类型,geometry 适合平面坐标系,geography 适合球面地理坐标,存经纬度和做地理计算时会用到。如果你有"附近的人"这类业务,建议直接学 geography + 空间索引的组合。hierarchyid 则是专门表示树形结构(组织架构、分类树)的类型,配合 GetAncestor、GetDescendant 等方法可以做层级查询,比传统 parent_id + 递归 CTE 在某些场景下更高效,但学习门槛和后续维护成本也要算进去。
5. 类型转换与建表选型的实战清单
5.1 隐式转换是怎么毁掉索引的
类型转换不只是"CAST 一下"这么简单,隐式转换往往更隐蔽。SQL Server 在两个不同类型的值做比较时,会按照数据类型优先级把优先级低的转成优先级高的。优先级大致是:
datetime2 > datetimeoffset > datetime > smalldatetime > date > time float > real > decimal > bigint > int > smallint > tinyint > bit nvarchar > nchar > varchar > char > varbinary > binary注意字符串类型里,nvarchar 优先级高于 varchar。也就是说,当一个 nvarchar 列和一个 varchar 值比较时,varchar 会被转成 nvarchar;只要转换方向是"列的类型转换为别的类型",这列上的索引基本就废了。比如你有一个 varchar 列并建了索引,查询条件写WHERE code = N'abc',这个 N 前缀让字符串变成 nvarchar,比较时 varchar 列要做隐式转换,索引用不上,全表扫描随之而来。反过来,如果是 nvarchar 列配 varchar 参数,则不会有这个问题,因为参数侧转换开销小得多。
同理,整数列和字符串参数比较,如果写WHERE id = '123',字符串常量会转成整数,一般问题不大;但如果是字符串列和整数参数WHERE code = 123,code 列要转成整数,索引失效。日常排查慢查询时,记得看看执行计划里有没有 "CONVERT_IMPLICIT" 字样,看到就说明有列级隐式转换,业务性能问题往往就藏在这里。
5.2 CAST、CONVERT、TRY_* 与 PARSE:该用哪个
CAST 是标准 SQL 的转换方式,简单直接:CAST(expression AS target_type)。CONVERT 是 SQL Server 特有写法,最大优势是支持 style 参数,尤其在日期格式化场景:
SELECT CONVERT(varchar(10), GETDATE(), 120) -- 2024-01-01 SELECT CONVERT(varchar(8), GETDATE(), 112) -- 20240101如果你要根据格式把字符串转成日期,CONVERT 的 style 非常方便。但注意 style 的可用范围有限,有些写法依赖本地化设置,跨环境时可能结果不一致。
TRY_CAST 和 TRY_CONVERT 则是安全转换:转换失败返回 NULL,而不是抛错。做数据清洗、导入外部数据时这是神器,可以先判断哪一行脏数据导致失败。PARSE 和 TRY_PARSE 依赖 .NET 文化,能做很复杂的本地化字符串解析,但性能和稳定性都不如前两者,正常情况下没必要用。
我的选型习惯是:纯粹转换类型用 CAST,日期格式化用 CONVERT,容错清洗用 TRY_CAST 或 TRY_CONVERT。至于 PARSE,基本不用,除非你要解析类似"January 1, 2024"这种带文化信息的字符串。
5.3 我的建表选型检查单
最后给一份我每次建表都会过一遍的清单,可以当模板抄:
- 主键:优先
int自增;数据量可能超过 21 亿或明确是流水表,用bigint;分布式多写场景才考虑uniqueidentifier+NEWSEQUENTIALID()。 - 金额:一律
decimal(18,2)起步,有更高精度需求再扩容,绝不用 float/money。 - 业务名称、描述、备注:默认
nvarchar(n),n 按业务实际最大值乘 1.5 到 2 倍估算,别一上来就 max。 - 编码、标识、英文代码:用
varchar(n),节省空间且语义清晰。 - 日期:只需要日期用
date,需要时间戳用datetime2,默认精度给datetime2(3)已经满足绝大多数毫秒级需求。 - 布尔:
bit。 - 状态/枚举:如果小于 256 个取值,用
tinyint或smallint,可读性靠文档和代码注释保证。 - 并发版本:加一个
rowversion列。 - 大字段:文件路径走字符串;实在要存内容用
varbinary(max)/nvarchar(max),但要有备份和空间增长的预案。 - 作为 WHERE 条件经常过滤的字符串列,优先确保类型和参数类型完全一致,别让隐式转换偷走索引。
数据类型这件事,说起来简单,但每一条都能展开成一个事故现场。我希望这篇能让你在下次建表时多留一个心眼:选类型不是在填表单,而是在替未来的查询性能、存储成本和数据准确性做决策。我自己每次新建表,都会顺手建一个字段字典文档,把每个字段的类型、长度、允许 NULL、业务含义写清楚,半年后再看,省下的沟通成本远超当初写文档的时间。这个小习惯也顺手分享给你。