三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

短信验证码实战:基于HttpClient与Redis的高可用安全架构设计

短信验证码实战:基于HttpClient与Redis的高可用安全架构设计

1. 项目概述:从“小行星编号”到短信验证码的实战

最近在为一个粉丝应援性质的小网站做后端开发,核心需求是实现一个短信验证码的发送与验证功能。这个项目有个挺有意思的内部代号——“小行星编号”,听起来有点科幻感,但其实指的就是那串6位数字的验证码。对于这类用户互动性强、需要快速验证身份的小型Web应用来说,短信验证码几乎是标配。它比图形验证码更安全,比邮箱验证更即时,是平衡用户体验与安全性的一个不错选择。

整个实现链路并不复杂,但细节决定成败。核心思路就是:用户在前端输入手机号并点击“获取验证码”后,后端服务需要生成一个随机码,通过第三方短信服务商的API将这个码发送到用户手机,同时在后端(通常是缓存数据库如Redis里)记录这个手机号和验证码的对应关系以及有效期。当用户提交验证时,后端再比对用户输入的码和之前存储的是否一致且未过期。

听起来简单,对吧?但在实际操作中,从服务商选型、API集成、到防刷策略、高可用设计,每一步都有不少门道。尤其是对于“易烊千玺小网站”这类可能在某些时刻面临突发访问量(比如新专辑发布、生日应援)的场景,系统的稳定性和抗压能力尤为重要。接下来,我就结合这次实战,把从零搭建一套可靠短信验证服务的完整过程、踩过的坑以及一些优化心得分享出来。

2. 技术选型与核心设计思路

在动手写代码之前,合理的选型和架构设计能避免后期很多麻烦。对于短信服务,首要决定是:自建短信网关还是使用第三方云服务?

2.1 第三方云服务 vs. 自建网关

对于绝大多数中小型项目,尤其是像我们这样的粉丝网站,强烈推荐直接使用成熟的第三方短信云服务,比如阿里云短信服务、腾讯云短信等。理由很充分:

  1. 成本与效率:自建网关涉及购买短信猫硬件、寻找稳定合作的运营商通道、处理复杂的通信协议(如CMPP、SGIP),初期投入大,维护成本高。云服务按量付费,没有发送量时几乎零成本。
  2. 到达率与稳定性:大型云服务商与多家运营商直连,通道质量高,短信到达率和速度有保障。自建通道容易因为各种问题(如签名报备、内容审核、通道抖动)导致发送失败或延迟。
  3. 功能与合规:云服务提供了完整的API、管理控制台、发送记录查询、数据统计和报警功能。同时,它们会协助处理运营商要求的签名报备、模板审核等合规流程,省心省力。

基于这些考虑,我们选择了阿里云短信服务作为本次项目的供应商。它的API文档清晰,SDK丰富,在国内的到达率口碑不错,并且提供了比较完善的防刷和安全策略支持。

2.2 核心架构设计

确定了服务商,我们来设计后端的核心处理流程。整个流程可以清晰地分为发送和验证两个阶段。

发送阶段流程:

  1. 接收请求:后端API接收到前端传来的手机号。
  2. 安全校验:进行频控(防止同一手机号频繁请求)、人机验证(可集成滑动验证码等)和手机号格式校验。
  3. 生成验证码:在服务端生成一个随机数(通常是4-6位数字)。
  4. 缓存与存储:将“手机号-验证码”键值对存入Redis,并设置一个较短的过期时间(如5分钟)。这里务必注意,存储的value应该是验证码经过哈希(如MD5或SHA)处理后的值,而非明文,以防万一Redis被入侵导致数据泄露。
  5. 调用短信API:构造请求参数(包括签名、模板ID、模板参数、手机号等),通过HTTP客户端调用阿里云短信服务的API。
  6. 响应处理:解析API返回结果,根据成功或失败,更新缓存状态(例如,发送失败可删除刚存入的缓存,或标记为无效),并将结果返回给前端。

验证阶段流程:

  1. 接收验证请求:后端API接收手机号和用户输入的验证码。
  2. 读取缓存:根据手机号从Redis中取出之前存储的哈希值。
  3. 比对验证:将用户输入的验证码进行相同的哈希计算,与缓存中的哈希值进行比对。同时检查该缓存记录是否已过期。
  4. 返回结果:比对成功且未过期,则验证通过,可以执行后续业务逻辑(如登录、注册),并立即删除或标记该缓存记录为已使用,防止重复验证。比对失败则返回具体错误信息。

注意:为什么存储哈希值而不是明文?这是纵深防御的一环。即使攻击者通过某种手段获取了Redis数据库的访问权限,他拿到的也只是验证码的哈希值,无法直接还原出原始验证码,从而无法冒用。虽然短信验证码本身有效期短,但这是一个良好的安全习惯。

2.3 HttpClient的选择与配置

在整个流程中,与阿里云API的通信依赖于一个可靠、高效的HTTP客户端。Java生态里,HttpClient(这里通常指Apache HttpClient或OkHttp)是首选。我们选择使用Apache HttpClient 4.x,因为它功能强大、配置灵活、社区成熟。

在配置HttpClient时,有几个关键点需要优化,以适应短信API调用的场景:

  • 连接超时与读取超时:短信API调用应该快速响应。设置连接超时(ConnectTimeout)为3-5秒,读取超时(SocketTimeout)为5-10秒比较合适。超时时间太短容易因网络波动导致失败,太长则会拖慢系统响应。
  • 连接池管理:务必使用连接池。频繁创建和销毁HTTP连接开销巨大。可以设置一个适中的最大连接数(如每路由50,总连接数200),并配置空闲连接存活时间。
  • 重试机制:对于网络超时等可重试的异常,可以实现一个自定义的重试策略。但需要注意,对于阿里云API返回的明确的业务错误(如签名错误、参数缺失),不应重试。
  • 日志记录:详细记录请求和响应的日志(注意脱敏,不要记录完整的验证码),对于后续排查问题至关重要。

3. 阿里云短信服务集成实战

理论讲完,我们进入实战环节。首先需要在阿里云控制台完成一系列准备工作。

3.1 阿里云控制台配置详解

  1. 开通服务:在阿里云产品中找到“短信服务”并开通。
  2. 创建签名:签名是显示在短信开头【】中的内容,用于标识发送方。对于企业网站,通常使用公司简称或备案的网站名称。对于粉丝站,可以尝试使用“易烊千玺官方后援会”之类的名称,但必须提前准备好对应的资质文件(如商标注册证、授权书等)用于审核。个人开发者有时可以使用APP名称或公众号名称。审核通常需要几个小时到一天。
  3. 创建模板:模板定义了短信的固定内容和变量。例如,一个验证码模板内容可能是:“您的验证码为${code},该验证码5分钟内有效,请勿泄露于他人。” 其中${code}就是变量,对应我们生成的6位数字。模板也需要审核,确保内容合规,无营销、诱导信息。
  4. 获取AccessKey:这是调用API的钥匙。在“AccessKey管理”中创建一对AccessKeyIdAccessKeySecret务必妥善保管AccessKeySecret,不要把它硬编码在客户端或公开的代码仓库中。最佳实践是将其存储在服务器的环境变量或配置中心。

3.2 基于HttpClient的API调用封装

阿里云提供了官方SDK,但理解其底层HTTP调用原理对于排查问题和进行定制化开发很有帮助。下面展示如何用Apache HttpClient手动构造一个发送请求。

首先,阿里云短信API需要对请求进行签名,签名算法是POP签名。为了简化,我们直接展示核心的请求构造和发送逻辑。假设我们已经有了签名计算的方法signRequest

import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import com.alibaba.fastjson.JSONObject; public class SmsService { private static final String ENDPOINT = "https://dysmsapi.aliyuncs.com"; private static final String ACCESS_KEY_ID = "your-access-key-id"; private static final String ACCESS_KEY_SECRET = "your-access-key-secret"; private static final String SIGN_NAME = "【你的签名】"; private static final String TEMPLATE_CODE = "SMS_123456789"; // 你的模板CODE public boolean sendVerificationCode(String phoneNumber, String code) { CloseableHttpClient httpClient = HttpClients.createDefault(); HttpPost httpPost = new HttpPost(ENDPOINT); // 1. 构建公共参数和业务参数 Map<String, String> params = new HashMap<>(); params.put("Action", "SendSms"); params.put("Version", "2017-05-25"); params.put("RegionId", "cn-hangzhou"); params.put("PhoneNumbers", phoneNumber); params.put("SignName", SIGN_NAME); params.put("TemplateCode", TEMPLATE_CODE); // 模板参数是一个JSON字符串 JSONObject templateParam = new JSONObject(); templateParam.put("code", code); params.put("TemplateParam", templateParam.toJSONString()); params.put("AccessKeyId", ACCESS_KEY_ID); params.put("Timestamp", generateTimestamp()); // 格式:yyyy-MM-dd'T'HH:mm:ss'Z' params.put("SignatureMethod", "HMAC-SHA1"); params.put("SignatureVersion", "1.0"); params.put("SignatureNonce", generateNonce()); // 唯一随机数,防重放 // 2. 计算签名并添加 String signature = signRequest(params, ACCESS_KEY_SECRET); params.put("Signature", signature); // 3. 将参数编码为表单格式,并设置为请求体 StringEntity entity = new StringEntity(formatFormData(params), "UTF-8"); entity.setContentType("application/x-www-form-urlencoded"); httpPost.setEntity(entity); try { // 4. 执行请求 CloseableHttpResponse response = httpClient.execute(httpPost); String responseBody = EntityUtils.toString(response.getEntity()); // 5. 解析响应 JSONObject result = JSONObject.parseObject(responseBody); if ("OK".equals(result.getString("Code"))) { return true; } else { // 记录错误日志,包括Code和Message System.err.println("短信发送失败: " + result.getString("Code") + " - " + result.getString("Message")); return false; } } catch (Exception e) { // 记录异常日志 e.printStackTrace(); return false; } finally { try { httpClient.close(); } catch (IOException e) { /* ignore */ } } } // 辅助方法:生成时间戳、随机数、格式化表单数据、计算签名等 private String generateTimestamp() { ... } private String generateNonce() { ... } private String formatFormData(Map<String, String> params) { ... } private String signRequest(Map<String, String> params, String accessKeySecret) { ... } }

实操心得:在实际生产中,HttpClient实例应该被设计成单例或通过连接池管理,而不是每次发送都创建新的。上面的示例为了清晰展示了完整流程,但在性能要求高的场景下,需要将CloseableHttpClient的创建提取到类初始化阶段。

3.3 验证码的生成、存储与验证逻辑

发送短信的核心是验证码本身的管理。这里我们使用Spring Boot + Redis的经典组合。

1. 生成验证码:

import java.util.Random; public class CodeGenerator { public static String generateDigitalCode(int length) { Random random = new Random(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < length; i++) { sb.append(random.nextInt(10)); // 生成0-9的数字 } return sb.toString(); } } // 使用:String code = CodeGenerator.generateDigitalCode(6);

避免使用Math.random(),它在高并发下可能表现不佳。java.util.Random或更安全的java.security.SecureRandom是更好的选择。

2. 存储验证码(使用Redis):我们使用Spring Data Redis来操作。存储的Key设计很重要,通常包含业务前缀、手机号和用途标识。

import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; @Service public class SmsCodeService { @Autowired private StringRedisTemplate redisTemplate; private static final String SMS_CODE_PREFIX = "sms:code:"; private static final long EXPIRE_SECONDS = 300; // 5分钟 public void saveCode(String phoneNumber, String code) { String key = SMS_CODE_PREFIX + phoneNumber; // 存储哈希值,而非明文 String hashedCode = DigestUtils.md5DigestAsHex(code.getBytes()); // 使用set命令,并设置过期时间 redisTemplate.opsForValue().set(key, hashedCode, EXPIRE_SECONDS, TimeUnit.SECONDS); // 可选:同时存储一个发送次数的计数器,用于频控 String countKey = "sms:count:" + phoneNumber; redisTemplate.opsForValue().increment(countKey); redisTemplate.expire(countKey, 3600, TimeUnit.SECONDS); // 1小时内计数 } }

3. 验证验证码:

public boolean verifyCode(String phoneNumber, String inputCode) { String key = SMS_CODE_PREFIX + phoneNumber; String storedHashedCode = redisTemplate.opsForValue().get(key); if (storedHashedCode == null) { return false; // 验证码不存在或已过期 } String inputHashedCode = DigestUtils.md5DigestAsHex(inputCode.getBytes()); boolean isValid = storedHashedCode.equals(inputHashedCode); if (isValid) { // 验证成功,立即删除,防止重复使用 redisTemplate.delete(key); } return isValid; }

4. 防刷与安全加固策略

短信服务是成本中心,也容易成为攻击目标(如短信轰炸)。必须实施有效的防刷策略。

4.1 多层次频控设计

单一的频控不够,我们需要一个多层次的防御体系:

  1. 手机号级别频控:同一个手机号在单位时间内(如1分钟、1小时、24小时)发送次数上限。可以用Redis的INCREXPIRE命令轻松实现,如上文saveCode方法中的计数器示例。阈值可以设置为:1分钟内1次,1小时内5次,24小时内10次。
  2. IP地址级别频控:防止攻击者通过更换手机号但使用同一IP进行轰炸。同样使用Redis记录IP的请求次数。阈值可以比手机号宽松一些,但要关注代理IP池的攻击。
  3. 业务令牌/图形验证码前置:在“获取验证码”按钮点击前,要求用户先完成一个简单的图形验证码或滑动拼图验证。这能有效拦截大部分机器脚本。可以将验证通过的令牌(Token)随手机号一起提交到后端,后端校验Token有效后才进行后续流程。
  4. 发送总量监控与告警:监控系统整体的短信发送量。如果发现某个时间段内发送量异常激增(比如比平时高出一个数量级),立即触发告警,以便人工介入排查。

4.2 验证码安全增强

除了存储哈希值,还有几点可以加强:

  • 复杂度:虽然通常是6位数字,但对于安全要求极高的场景,可以考虑使用6位数字字母混合。但要权衡用户体验,因为用户需要在手机短信和应用间切换输入。
  • 一次性:确保验证码在验证成功后立即失效(从Redis中删除),这是最基本的要求。
  • 防猜测:验证码必须是足够随机的,避免使用递增序列或简单模式。SecureRandomRandom能提供更好的密码学强度。
  • 时效性:过期时间不宜过长,5分钟是常见选择。时间太短可能用户来不及操作,太长则增加安全风险。

4.3 异步化与降级方案

在高并发场景下,同步调用短信API可能会阻塞线程,导致服务响应变慢甚至超时。

异步发送:可以将发送短信的任务提交到一个线程池或消息队列(如RabbitMQ, Kafka)。后端在生成验证码并存入Redis后,立即返回“发送中”的状态给前端,然后由后台任务异步执行实际的API调用。这样前端响应迅速,即使短信服务暂时抖动,也不会直接影响主业务流程。

// 伪代码示例:使用Spring的@Async @Service public class AsyncSmsService { @Autowired private SmsSender smsSender; // 封装了HttpClient调用的发送器 @Async("smsTaskExecutor") // 指定一个专用的线程池 public void sendSmsAsync(String phoneNumber, String code) { try { smsSender.send(phoneNumber, code); } catch (Exception e) { // 异步任务需要做好异常处理和日志记录 log.error("异步发送短信失败: {}", phoneNumber, e); // 可以考虑重试机制,或者将失败任务持久化,后续补偿 } } }

降级方案:当短信服务商接口完全不可用或持续失败时,需要有降级策略,保证核心业务不中断。

  1. 本地日志降级:将手机号和验证码记录到服务器本地日志文件或数据库。运营人员可以手动查看并告知用户(仅适用于极小范围或内部系统)。
  2. 备用通道切换:如果预算允许,可以集成两家短信服务商(如阿里云和腾讯云)。当主通道失败率达到一定阈值时,自动切换至备用通道。
  3. 语音验证码降级:作为一种备选方案,当短信发送失败时,尝试调用语音验证码API,通过电话播报验证码。阿里云等厂商也提供此服务。

5. 常见问题排查与性能优化

在实际部署和运行中,肯定会遇到各种问题。这里记录一些典型场景和排查思路。

5.1 发送失败问题排查清单

当收到用户反馈“收不到验证码”时,可以按照以下步骤排查:

问题现象可能原因排查步骤与解决方案
控制台显示发送成功,但用户未收到1. 手机号填写错误。
2. 用户手机拦截了陌生号码或营销短信。
3. 运营商网络延迟(极少见)。
4. 短信内容触发了运营商的敏感词过滤。
1. 核对日志中记录的发送目标手机号。
2. 让用户检查短信垃圾箱,或关闭短信拦截功能。
3. 在阿里云控制台查看“发送记录”,确认状态是否为“发送成功”。可尝试在控制台手动使用相同模板和内容发送测试。
4. 检查短信模板内容,避免包含URL、特殊符号、敏感词汇。联系服务商确认模板是否正常。
API调用返回明确错误码1.isv.INVALID_PARAMETERS:参数缺失或格式错误。
2.isv.SIGNATURE_ILLEGAL:签名未报备或审核未通过。
3.isv.TEMPLATE_ILLEGAL:模板未报备或审核未通过。
4.isp.RAM_PERMISSION_DENY:AccessKey权限不足。
1. 仔细检查请求参数,特别是PhoneNumbers(需国内手机号,国际号码需带区号)、SignNameTemplateCodeTemplateParam的JSON格式。
2. 登录阿里云控制台,确认签名状态为“审核通过”。
3. 确认模板状态为“审核通过”。
4. 检查使用的AccessKey是否拥有SendSms操作的权限。
API调用超时或连接被拒绝1. 服务器网络问题,无法访问公网。
2. HttpClient配置超时时间过短。
3. 阿里云服务端临时故障。
1. 在服务器上执行curltelnet命令测试到dysmsapi.aliyuncs.com的网络连通性。
2. 适当增加ConnectTimeoutSocketTimeout,并检查连接池配置。
3. 查看阿里云短信服务的“健康状态”页面,或等待一段时间后重试。
验证时提示“验证码错误”或“已过期”1. 用户输入错误。
2. 验证码已过期(超过5分钟)。
3. Redis服务异常,导致存储失败或读取不到。
4. 多台服务器时钟不同步,影响过期判断。
1. 提示用户仔细核对。
2. 前端可增加倒计时显示,后端确保EXPIRE设置正确。
3. 检查Redis连接状态、内存使用情况,查看是否有持久化或主从同步问题。
4. 确保服务器使用NTP服务进行时间同步。

5.2 性能优化要点

当网站搞大型活动,瞬间涌来大量验证码请求时,系统要能扛得住。

  1. Redis性能:验证码操作是典型的“写一次,读一次”的高并发场景。确保Redis部署为高可用模式(如哨兵或集群),并且与业务服务器同机房或通过高速内网连接,降低延迟。可以考虑使用Pipeline技术批量处理一些统计计数操作。
  2. HttpClient优化
    • 使用连接池:这是最重要的优化。调整PoolingHttpClientConnectionManager的参数,如MaxTotal(总连接数)、DefaultMaxPerRoute(每路由最大连接数),根据预估的QPS进行调整。
    • 合理设置超时:避免因个别慢请求拖累整个连接池。
    • 启用重试:对可重试的异常(如ConnectTimeoutException,SocketTimeoutException)配置幂等的重试机制(1-2次)。
  3. 服务解耦:如前所述,将耗时的短信API调用异步化,通过消息队列解耦。这样发送请求的接口可以快速响应,将压力转移到后台的消费者服务,消费者服务可以根据处理能力水平扩展。
  4. 缓存验证结果:对于登录等场景,验证码验证通过后,通常会颁发一个Token(如JWT)。可以将这个Token与用户状态缓存在Redis中,并设置一个合理的过期时间(如30分钟),避免用户短时间内重复操作时需要再次短信验证。

5.3 监控与告警

一个健壮的系统离不开监控。

  • 关键指标监控
    • 发送成功率:成功调用API次数 / 总调用次数。低于99.9%需要关注。
    • API响应时间:P50, P95, P99的延迟。如果延迟显著增加,可能预示网络或服务商问题。
    • Redis操作延迟与命中率:监控SETGET命令的延迟。
    • 各频控维度的请求量:监控每分钟/每小时按手机号、IP的请求量,及时发现异常爬取或攻击行为。
  • 告警设置
    • 发送成功率在5分钟内持续低于95%。
    • API平均响应时间超过2秒。
    • 单个IP地址在1分钟内请求发送验证码超过50次(疑似轰炸)。
    • Redis连接失败或内存使用率超过80%。

通过以上从设计、开发、安全到运维的全流程拆解,一个看似简单的“发送验证码”功能,背后需要考虑的细节远比想象的多。特别是对于有突发流量的粉丝社区类网站,提前做好架构设计和容量规划,才能在各种活动来临时稳如泰山。最后,再强调一个容易被忽略的点:一定要在用户协议和隐私政策中明确说明收集手机号用于发送验证码的目的,并确保数据安全,这是法律合规的基本要求。把这些都做到位,你的“小行星编号”系统才能真正既好用又可靠。

← 返回列表