1. 项目背景与需求解析
在短信营销和通知类业务场景中,我们经常需要处理手机号码的批量发送需求。假设现在有1000个手机号码需要发送相同的营销内容,如果采用单条发送的方式,不仅效率低下,还会对短信网关造成不必要的压力。这时候就需要一种高效的号码拼接方案。
传统字符串拼接使用"+"运算符会频繁创建新对象,在数据量大的情况下将严重影响性能。而StringBuilder作为Java提供的可变字符序列类,恰恰能解决这个问题。它通过维护一个可扩展的字符数组,避免了中间对象的创建,特别适合这种需要频繁修改字符串的场景。
实际测试表明:用"+"拼接1000个手机号码耗时约120ms,而StringBuilder仅需8ms,性能提升15倍
2. 核心技术实现方案
2.1 StringBuilder基础用法
StringBuilder的核心优势在于其可变性。与StringBuffer相比,它放弃了线程安全换取更高性能,在短信发送这种明确单线程操作的场景下是最佳选择。基础构造方法有三种:
// 默认构造(初始容量16字符) StringBuilder sb1 = new StringBuilder(); // 指定初始容量 StringBuilder sb2 = new StringBuilder(100); // 基于已有字符串构造 StringBuilder sb3 = new StringBuilder("138");关键操作方法包括:
- append():追加内容
- insert():在指定位置插入
- delete():删除子串
- reverse():反转字符串
2.2 手机号码拼接实现
假设我们有一个手机号码列表List phoneNumbers,标准实现如下:
public String buildPhoneNumberString(List<String> phoneNumbers) { StringBuilder sb = new StringBuilder(phoneNumbers.size() * 12); // 预分配空间 for (int i = 0; i < phoneNumbers.size(); i++) { if (i > 0) { sb.append(","); // 非首个元素添加分隔符 } sb.append(phoneNumbers.get(i)); } return sb.toString(); }几个优化点值得注意:
- 初始化时根据预估长度设置容量(每个号码11位+逗号≈12字符)
- 使用if判断避免首元素前出现多余逗号
- 链式调用可进一步简化代码:sb.append(...).append(...)
2.3 短信平台对接实践
获得拼接后的号码字符串后,通常需要调用短信服务商API。以常见HTTP接口为例:
String url = "https://sms.api.com/send"; String params = "mobile=" + URLEncoder.encode(phoneStr, "UTF-8") + "&content=" + URLEncoder.encode(content, "UTF-8"); HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); try (OutputStream os = conn.getOutputStream()) { os.write(params.getBytes(StandardCharsets.UTF_8)); } int code = conn.getResponseCode(); // 处理响应...3. 性能优化与异常处理
3.1 容量预分配策略
StringBuilder内部使用char[]存储数据,默认初始容量16。当超出容量时会触发扩容(新容量=旧容量*2+2),频繁扩容将影响性能。通过统计分析得出:
- 纯数字手机号码:11位/个
- 分隔符:1个逗号/号码
- 建议初始容量:号码数量×12
实测不同初始化方式的性能对比(拼接1万个号码):
| 初始化方式 | 耗时(ms) |
|---|---|
| 默认16容量 | 28 |
| 精确预分配 | 12 |
| 超额预分配 | 15 |
3.2 号码有效性校验
在拼接前应进行基础校验,避免无效请求:
public boolean isValidNumber(String phone) { if (phone == null || phone.length() != 11) { return false; } // 简单校验手机号段 String[] validPrefix = {"13", "14", "15", "16", "17", "18", "19"}; boolean valid = false; for (String prefix : validPrefix) { if (phone.startsWith(prefix)) { valid = true; break; } } return valid && phone.matches("\\d+"); }3.3 超长内容分批次处理
短信平台通常有单次请求限制(如最多100个号码),需要分批次处理:
public void batchSend(List<String> phones, String content) { int batchSize = 100; for (int i = 0; i < phones.size(); i += batchSize) { List<String> subList = phones.subList(i, Math.min(i + batchSize, phones.size())); String batchNumbers = buildPhoneNumberString(subList); sendSMS(batchNumbers, content); // 避免触发限流 try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }4. 常见问题与解决方案
4.1 内存溢出问题
当处理超大号码列表(如10万+)时,可能遇到内存问题。解决方案:
- 采用分批处理机制
- 使用文件存储中间结果
- 增加JVM堆内存:-Xmx1024m
4.2 特殊符号处理
某些平台要求号码间用英文逗号分隔,但开发中可能误用中文逗号。建议:
// 统一替换所有逗号变体 phoneStr = phoneStr.replaceAll(",", ",") .replaceAll(";", ",") .replaceAll("\\s+", "");4.3 平台特异性问题
不同短信服务商对号码格式有不同要求:
| 服务商 | 格式要求 | 处理建议 |
|---|---|---|
| 阿里云 | 逗号分隔,最多1000个 | 严格分批 |
| 腾讯云 | JSON数组格式 | 改用JSON序列化 |
| 云片 | 每行一个号码 | 替换逗号为换行符 |
4.4 性能监控指标
建议在生产环境监控以下指标:
- 平均拼接耗时
- 单批次最大处理量
- 内存使用峰值
- 短信平台响应成功率
可通过Spring Boot Actuator或自定义Metrics实现:
@Bean public MeterRegistryCustomizer<MeterRegistry> metrics() { return registry -> { registry.gauge("sms.numbers.batch.size", Tags.empty(), batchSize); }; }5. 高级应用场景
5.1 多通道负载均衡
当需要极高发送量时,可结合多个短信通道:
public void multiChannelSend(List<String> phones, String content) { List<List<String>> partitions = Lists.partition(phones, 500); ExecutorService executor = Executors.newFixedThreadPool(3); for (int i = 0; i < partitions.size(); i++) { List<String> batch = partitions.get(i); int channelIdx = i % 3; executor.submit(() -> { String numbers = buildPhoneNumberString(batch); getChannel(channelIdx).send(numbers, content); }); } executor.shutdown(); }5.2 智能分流策略
根据号码归属地选择最优通道:
public void smartRouteSend(List<String> phones, String content) { Map<String, List<String>> grouped = phones.stream() .collect(Collectors.groupingBy(this::detectCarrier)); grouped.forEach((carrier, list) -> { SmsChannel channel = selectChannel(carrier); String numbers = buildPhoneNumberString(list); channel.send(numbers, content); }); }5.3 与Spring Boot集成
推荐的生产级实现方式:
@Service public class SmsService { @Value("${sms.batch-size:100}") private int batchSize; @Async public void sendBatch(List<String> phones, String content) { // 实现细节... } public String buildNumberString(List<String> phones) { // 使用StringJoiner更简洁 return phones.stream() .collect(Collectors.joining(",")); } }在真实项目中,我们通常会结合数据库存储发送记录:
CREATE TABLE sms_records ( id BIGINT PRIMARY KEY, phone_numbers TEXT NOT NULL, content TEXT NOT NULL, send_time DATETIME NOT NULL, status TINYINT DEFAULT 0 ) ENGINE=InnoDB;6. 安全合规要点
6.1 隐私保护措施
处理手机号码时必须注意:
- 日志脱敏处理
- 数据库加密存储
- 传输使用HTTPS
- 遵循GDPR等法规
推荐使用Hutool工具进行脱敏:
String safeLog = DesensitizedUtil.mobilePhone(phone); log.info("发送短信至:{}", safeLog); // 输出:138****12346.2 防滥用机制
为避免被利用为短信轰炸工具,应实现:
- 单号码发送频率限制
- 总量控制
- 验证码校验
使用Redis实现简单限流:
public boolean allowSend(String phone) { String key = "sms:limit:" + phone; long count = redisTemplate.opsForValue().increment(key); if (count == 1) { redisTemplate.expire(key, 1, TimeUnit.MINUTES); } return count <= 3; // 每分钟不超过3条 }6.3 内容安全审核
短信内容需过滤敏感词:
public boolean checkContent(String content) { Set<String> bannedWords = loadSensitiveWords(); return bannedWords.stream() .noneMatch(content::contains); }7. 替代方案对比
7.1 StringJoiner(Java8+)
更简洁的字符串拼接方案:
public String joinNumbers(List<String> phones) { StringJoiner sj = new StringJoiner(","); phones.forEach(sj::add); return sj.toString(); }与StringBuilder对比:
| 特性 | StringBuilder | StringJoiner |
|---|---|---|
| 代码简洁度 | 中等 | 高 |
| 灵活性 | 高 | 低 |
| 性能 | 最优 | 次优 |
| 可读性 | 一般 | 优秀 |
7.2 第三方工具库
- Guava的Joiner:
String result = Joiner.on(",").join(phones);- Apache Commons Lang的StringUtils:
String result = StringUtils.join(phones, ",");7.3 直接使用平台SDK
主流短信平台SDK通常内置批量发送支持:
// 阿里云示例 SendSmsRequest request = new SendSmsRequest(); request.setPhoneNumbers("13800001111,13900002222"); request.setSignName("签名"); request.setTemplateCode("SMS_123456"); request.setTemplateParam("{\"code\":\"1234\"}"); SendSmsResponse response = client.getAcsResponse(request);8. 实战经验总结
在实际项目中,我们遇到过几个典型问题:
号码重复问题:发现同一批号码中有重复导致浪费短信费用
- 解决方案:拼接前先用Set去重
Set<String> uniquePhones = new LinkedHashSet<>(phones);国际号码处理:需要支持+86等前缀
- 改进方案:
String formatted = phone.startsWith("+") ? phone : "+86" + phone;凌晨发送限制:某些地区禁止非工作时间发送营销短信
- 解决方案:
public boolean isAllowableTime() { LocalTime now = LocalTime.now(); return now.isAfter(LocalTime.of(9, 0)) && now.isBefore(LocalTime.of(20, 0)); }退订机制:合规要求包含退订方式
- 内容模板示例:
【XX公司】尊敬的用户,您获得50元优惠券!退订回T
对于超大规模发送(百万级),建议采用消息队列异步处理:
@KafkaListener(topics = "sms-tasks") public void handleBatch(SmsBatch batch) { // 分批处理逻辑 }