返回

MySQL REGEXP字符类陷阱:_与-位置导致匹配失败?(含LeetCode)

mysql

MySQL REGEXP 踩坑:下划线 (_) 和连字符 (-) 在字符类中的『恩怨情仇』(LeetCode 1517 实战)

写 SQL 时用正则表达式是个挺方便的事儿,尤其是在处理像校验邮箱格式这种有点麻烦的文本匹配任务时。MySQL 提供了 REGEXP (或者 RLIKE) 函数来干这个。不过,就像很多强大的工具一样,这里面也有些小坑。

今天咱们就来聊聊 LeetCode 第 1517 题 "Find Valid E-mails" 中遇到的一个具体问题:两个看起来几乎一模一样的正则表达式,在处理包含下划线的邮箱时,表现却不一样。

问题现象:两个『相似』的正则,一个『管用』一个『拉胯』

题目的要求是找出符合特定规则的有效邮箱:

  1. 邮箱由前缀名域名 组成,中间用 @ 连接。
  2. 前缀名 必须以字母开头,后面可以跟字母(大小写都行)、数字、下划线 (_)、点 (.) 或 连字符 (-)。
  3. 域名 固定为 @leetcode.com

很多同学(包括我一开始)可能会写出类似下面的 SQL 查询,利用 REGEXP 来筛选 Users 表中的 mail 字段:

SELECT * FROM Users
WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com
SELECT * FROM Users
WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com$';
-- 或者用 RLIKE,效果一样
-- WHERE mail RLIKE '^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com$';
#x27;
; -- 或者用 RLIKE,效果一样 -- WHERE mail RLIKE '^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com
SELECT * FROM Users
WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com$';
-- 或者用 RLIKE,效果一样
-- WHERE mail RLIKE '^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com$';
#x27;;

上面这个表达式通常能正常工作,能匹配像 [email protected] 这样的邮箱。

然而,有同学可能会把字符类 [a-zA-Z0-9._-]* 里的 _- 顺序换一下,变成这样:

-- 注意这里:[a-zA-Z0-9.-_]*
SELECT * FROM Users
WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9.-_]*@leetcode\\.com
-- 注意这里:[a-zA-Z0-9.-_]*
SELECT * FROM Users
WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9.-_]*@leetcode\\.com$';
#x27;
;

按理说,字符类 [...] 里的字符顺序应该不影响匹配结果才对,[._-][.-_] 都表示匹配 点、下划线、或连字符 中的任意一个。但实际测试中,当邮箱前缀包含下划线时(例如 test_[email protected]),第二个正则表达式就匹配不上了 !这是咋回事呢?

刨根问底:字符类里的连字符 (-) 不是『善茬』

问题的关键就出在正则表达式的字符类 [...] 和里面的连字符 - 上。

在正则表达式里,方括号 [] 用来定义一个字符集合 ,匹配方括号中任意一个 字符。例如 [abc] 能匹配 'a'、'b' 或 'c'。

但是,连字符 - 在字符类里通常扮演一个特殊角色 :定义字符范围 。最常见的例子就是 [a-z] 匹配所有小写字母,[0-9] 匹配所有数字。

当你把连字符 - 放在两个字符之间时,比如 [.-_],正则表达式引擎就可能懵了,或者说,它会按照预设的规则去理解。它可能会尝试解释成 “匹配 点 (.) 或者 匹配从连字符 (-) 到下划线 (_) 这个范围内的所有字符”。

这个“范围”到底包含哪些字符,取决于字符集 (character set)和排序规则 (collation)。不同的设置下,连字符和下划线之间可能没有字符,也可能有其他字符,甚至这种范围表示本身就可能不被某些正则引擎明确支持或者行为怪异,特别是涉及到符号时。

最重要的来了 :如果 - 被解释为范围指示符,那它本身就不再代表普通的连字符字符了 !这就是为什么 ^[a-zA-Z][a-zA-Z0-9.-_]*@leetcode\\.com$ 这个模式匹配不到包含 _ 的邮箱。因为它把 .-_ 里的 - 当成了范围符号,而这个范围的定义又可能很微妙(或无效),导致匹配行为不符合预期,甚至无法正确匹配范围边界的 _

那么,第一个能正常工作的模式 ^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com$ 又是怎么回事呢?

这里运用了处理字符类中连字符的一个标准技巧要想让 - 在字符类里只表示它自己(一个普通的连字符),而不是范围指示符,最好的办法是把它放在字符类的最前面或者最后面。

[a-zA-Z0-9._-] 这个写法里,- 被放在了最后面。这样,正则表达式引擎就不会把它看作范围符号,而是老老实实地把它当成一个普通的连字符来匹配。所以,[._-] 就明确表示:匹配 点、下划线、或连字符 三者中的任意一个。这就对了!

小结一下原因

  • 正则表达式 [...] 内的 - 默认是范围指示符。
  • [.-_] 写法中,- 位于 ._ 之间,被 MySQL 的 REGEXP 引擎误解(或按范围规则处理),导致行为不符预期,匹配不到 _
  • [._-] 写法中,- 位于末尾,被明确视为普通字符,正确匹配 点、下划线、连字符。

额外提一句 MySQL 的转义问题

你可能注意到上面的模式用了 @leetcode\\.com$ 而不是 @leetcode.com$。这是因为:

  1. 在正则表达式语法中,点 . 是特殊字符,表示匹配任意单个字符(换行符除外)。为了匹配真实的 点 字符,需要转义,写成 \.
  2. 在 SQL 字符串字面量 中,反斜杠 \ 本身也是转义符 (除非设置了 NO_BACKSLASH_ESCAPES SQL 模式)。所以,你想传递给 REGEXP 函数的 \. 字符串,在 SQL 语句里就得写成 \\.。第一个 \ 转义了第二个 \,这样 SQL 解析器处理后,传递给 REGEXP 的才是正确的 \.

同理,对于模式中的 \_\-,在 SQL 字符串里写出来,经过 SQL 解析器的第一层转义,实际交给 REGEXP 引擎的就是 _-。好在,在 POSIX ERE(MySQL REGEXP 默认使用的规范)的字符类 [...] 内部,下划线 _ 和 点 . 通常不需要特殊转义。所以,即使不写 \_\. (在[]内),直接写 _. 也能工作。但是连字符 - 的位置问题是绕不开的。而域名中的 . 不在字符类内,必须 转义,所以用 \\.

解决方案:明明白白写正则

搞清楚了原因,解决起来就简单了。核心就是确保字符类 [...] 里的连字符 - 不被误解。

方案一:把连字符放在最后(推荐)

这是最稳妥、最符合直觉、也最具可移植性的方法。

  • 原理 :将 - 放在字符类的末尾(或开头),明确告诉正则引擎,这个 - 就是个普通字符,别想太多。

  • 代码示例

    SELECT user_id, mail
    FROM Users
    WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com
    SELECT user_id, mail
    FROM Users
    WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9._-]*@leetcode\\.com$';
    
    #x27;
    ;

    这个模式清晰地表达了:前缀名的第二部分可以是字母、数字、点、下划线、或者连字符 。域名部分 @leetcode 后面跟一个真实的 ,然后是 com 结尾。

  • 解释

    • ^: 匹配字符串的开始。
    • [a-zA-Z]: 匹配第一个字符,必须是大小写字母。
    • [a-zA-Z0-9._-]*: 匹配零个或多个后续字符。这些字符可以是:
      • a-zA-Z: 大小写字母。
      • 0-9: 数字。
      • .: 普通的点字符 (在[]内,点通常不需要转义,写 \. 也行,但没必要)。
      • _: 普通的下划线字符。
      • -: 普通的连字符(因为它在最后)。
    • @leetcode: 匹配固定的字符串 @leetcode
    • \\.: 匹配一个真实的 . 字符(注意 SQL 里的双反斜杠 \\)。
    • com: 匹配固定的字符串 com
    • $: 匹配字符串的结束。
  • 安全建议 :对于邮箱验证这类场景,REGEXP 本身通常不涉及直接的 SQL 注入风险(只要正则表达式是固定的,不是由用户输入的)。主要的风险还是常规的 SQL 注入,需要确保查询的其他部分是安全的(比如使用了参数化查询)。

方案二:使用 POSIX 字符类(进阶技巧)

MySQL 的 REGEXP 支持 POSIX 字符类,比如 [:alnum:] 代表字母和数字,[:punct:] 代表标点符号。有时可以组合使用它们,但要注意范围可能比你预期的宽泛。

  • 原理 :使用预定义的字符集合名称代替手动列举。
  • 代码示例
    -- 注意:[:punct:] 包含的标点符号可能比 . _ - 要多,
    -- 而且不同环境下的 [:punct:] 可能略有差异,需要谨慎测试。
    -- 可能还需要排除某些字符。这不是针对本题的最佳方案,仅作演示。
    SELECT user_id, mail
    FROM Users
    WHERE mail REGEXP '^[[:alpha:]][[:alnum:]._ -]*@leetcode\\.com
    -- 注意:[:punct:] 包含的标点符号可能比 . _ - 要多,
    -- 而且不同环境下的 [:punct:] 可能略有差异,需要谨慎测试。
    -- 可能还需要排除某些字符。这不是针对本题的最佳方案,仅作演示。
    SELECT user_id, mail
    FROM Users
    WHERE mail REGEXP '^[[:alpha:]][[:alnum:]._ -]*@leetcode\\.com$';
    -- 注意这里把 . _ - 单独列出,并把 - 放最后。
    -- 或者更复杂的写法,尝试用 [:alnum:] 和单独的 ._-
    -- '^[[:alpha:]]([[:alnum:]]|[._-])*@leetcode\\.com$' 这样的逻辑
    
    #x27;; -- 注意这里把 . _ - 单独列出,并把 - 放最后。 -- 或者更复杂的写法,尝试用 [:alnum:] 和单独的 ._- -- '^[[:alpha:]]([[:alnum:]]|[._-])*@leetcode\\.com
    -- 注意:[:punct:] 包含的标点符号可能比 . _ - 要多,
    -- 而且不同环境下的 [:punct:] 可能略有差异,需要谨慎测试。
    -- 可能还需要排除某些字符。这不是针对本题的最佳方案,仅作演示。
    SELECT user_id, mail
    FROM Users
    WHERE mail REGEXP '^[[:alpha:]][[:alnum:]._ -]*@leetcode\\.com$';
    -- 注意这里把 . _ - 单独列出,并把 - 放最后。
    -- 或者更复杂的写法,尝试用 [:alnum:] 和单独的 ._-
    -- '^[[:alpha:]]([[:alnum:]]|[._-])*@leetcode\\.com$' 这样的逻辑
    
    #x27; 这样的逻辑
  • 解释[:alpha:] 匹配字母,[:alnum:] 匹配字母和数字。在第二个方括号里,我们依然保留了 ._- 并将 - 放到了最后,因为它可能不在 [:alnum:] 或你选用的其他 POSIX 类里。这种方式在本例中没带来多少简化,反而可能因为 [:punct:] 等类的范围问题引入不确定性。所以对这个问题来说,不如方案一直接。
  • 适用场景 :当你需要匹配非常标准的、范围明确的集合(如所有字母、所有控制字符)时,POSIX 类会很方便。但在需要精确控制包含哪些特殊符号时,不如手动列出清晰。

方案三:反斜杠转义连字符 \-?(理论上可行,但不推荐)

有些正则表达式方言允许在字符类内部使用 \ 来转义 -,使其失去特殊含义,比如 [.\-_]

  • 原理 :强制将 - 视为普通字符。
  • 代码示例 (理论)
    -- 理论上可能工作,但可移植性和清晰度不如方案一
    SELECT user_id, mail
    FROM Users
    WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9.\\-_]*@leetcode\\.com
    -- 理论上可能工作,但可移植性和清晰度不如方案一
    SELECT user_id, mail
    FROM Users
    WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9.\\-_]*@leetcode\\.com$';
    -- 注意:SQL 字符串转义后,给 REGEXP 的是 [.-_],
    -- 需要依赖 REGEXP 引擎是否接受 \- 作为字面量。
    -- 但鉴于原始问题中带 \- 的模式 ( pattern 2) 工作不正常,
    -- 这个方法在 MySQL 上可能并不可靠或行为非预期。
    
    #x27;
    ; -- 注意:SQL 字符串转义后,给 REGEXP 的是 [.-_], -- 需要依赖 REGEXP 引擎是否接受 \- 作为字面量。 -- 但鉴于原始问题中带 \- 的模式 ( pattern 2) 工作不正常, -- 这个方法在 MySQL 上可能并不可靠或行为非预期。
  • 为什么不推荐
    1. 可移植性差 :并非所有正则引擎都支持或以相同方式支持在 [] 内转义 -
    2. MySQL 行为存疑 :根据问题,原始的包含 \-- 不在末尾的模式 ^[a-zA-Z][a-zA-Z0-9\.\-\_]*@leetcode[\.]com$ (在问题里被错误地说成不工作,但实际上可能是用户提供的第二个,即 .-_ 顺序的版本不工作。这里我们假设用户指的就是[.-_]这种顺序导致的问题) 在 MySQL 中行为异常。这表明依赖 \- 的转义可能在 MySQL 中存在 нюансы (细微差别/坑)。
    3. 不够清晰 :将 - 放在结尾 [._-] 的意图更明确、更易读懂。

结论就是:老老实实把连字符 - 放在字符类的最后(或最前),用 [._-] 这种形式,是最靠谱的办法。

写在最后 (但不是总结陈词)

正则表达式是个强大的工具,但也布满了细节和潜在的『陷阱』。字符类里的连字符 - 就是一个典型例子。不同位置,含义大不同。理解了它的特殊性,以及如何通过放置在开头或末尾来强制其表示字面意思,就能避免很多不必要的麻烦。下次在 MySQL 或其他地方用正则遇到类似问题时,记得检查一下你的连字符 - 是不是『安分守己』地待在它该待的位置。