尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

基于MurmurHash与Redis构建高并发短链服务:原理、实战与生产环境优化

基于MurmurHash与Redis构建高并发短链服务:原理、实战与生产环境优化 1. 项目缘起从“又臭又长”的链接到“短小精悍”的短链你有没有遇到过这种情况在微信群里分享一个商品链接结果消息气泡被一长串夹杂着各种参数的URL撑得老长不仅不美观还经常因为字符太多被某些平台截断导致链接失效。或者在制作宣传海报、印刷物料时一个长得离谱的链接不仅占地方还显得非常不专业。更别提那些需要追踪点击来源、分析用户行为的运营场景了原始链接里可能已经包含了UTM参数再加上平台自身的跟踪参数长度简直不忍直视。这就是“长链接转短链接”这个看似简单却无处不在的需求诞生的背景。它绝不仅仅是为了“变短”而变短。一个成熟的短链接服务背后至少解决了四个核心痛点美观与简洁、防截断与高兼容性、数据追踪与分析、品牌与安全。想象一下https://yourbrand.com/a1b2c3这样的链接是不是比原始链接更容易记忆、传播和印刷最近我在为一个内部运营活动搭建短链服务时就重新梳理了整个技术栈。网上教程很多但要么过于简单一个哈希函数搞定要么过于复杂直接上企业级方案。我希望找到一个平衡点既能支撑一定的并发量比如日生成百万级点击千万级又足够轻量、易于理解和维护。在这个过程中哈希算法特别是MurmurHash的选择、Redis的高效运用以及如何应对“客户端连接数爆满”这类生产环境问题成了技术实现的关键。今天我就把自己从设计到踩坑再到优化的完整过程分享出来这不仅仅是一个功能实现更是一次关于高并发、高可用服务设计的实战演练。2. 核心设计短链生成的“道”与“术”在动手写代码之前我们必须想清楚短链服务的核心逻辑。它本质上是一个“键值对”的存储与重定向服务用户给我一个长链接Long URL我生成一个唯一的短码Short Code与之绑定当用户访问短链时我根据短码找到对应的长链接然后通过HTTP 302重定向跳转过去。这里面的技术难点集中在两点如何生成短码和如何存储与查询2.1 短码生成算法为什么是MurmurHash生成短码首先想到的是用哈希函数。把长链接“压缩”成一个固定长度的字符串。常见的哈希算法有MD5、SHA-1等但它们生成的字符串太长32位或40位十六进制不适合做短码。我们需要的是一个输出长度可控、碰撞概率极低、且速度非常快的哈希算法。这就是MurmurHash闪亮登场的理由。它是一种非加密型哈希函数设计初衷就是为了快。在已知的各类测试中MurmurHash3的速度远超MD5、SHA-1甚至比一些简单的哈希如FNV还要快。对于短链服务这种对性能极其敏感的场景每次生成、每次跳转都要计算或查询速度就是生命线。但MurmurHash输出的是32位或128位的整型数值我们需要将其转换为更短的字符串。通常的做法是将其转换为62进制a-zA-Z0-9共62个字符。一个32位整数最大值约42亿用62进制表示最多只需要6个字符62^6 ≈ 560亿。6位字符对于短链来说长度非常合适。我们来算一下碰撞概率。假设我们使用了MurmurHash3的32位版本哈希空间约为42.9亿。根据生日悖论当存储的键值对数量达到约7.7万时就有50%的概率发生一次碰撞。这对于一个大型服务来说显然不够。因此在实际生产中我们绝不能直接使用裸的32位哈希值作为唯一键。解决方案是“哈希 唯一标识”。具体流程如下对输入的长URL进行规范化如去除末尾空格统一大小写等然后计算其MurmurHash3 32位值。将该哈希值转换为62进制得到一个短码例如a1b2c3。以这个短码为键去存储如Redis中查询。如果不存在直接将短码 - 长URL的映射存储起来。为了防止并发写入导致的数据覆盖可以使用Redis的SETNXSet if Not Exists命令。如果已存在说明发生了哈希碰撞或者同一个长URL被重复提交。此时需要对比存储中的长URL是否与当前长URL一致。如果一致说明是同一个长URL直接返回已有的短码即可实现幂等性。如果不一致说明发生了真正的哈希碰撞。此时我们在原始短码后追加一个递增序号如a1b2c3_1或一个随机字符串生成一个新的短码然后重复步骤3的检查。这个过程称为“加盐重试”。通过这个机制我们既享受了MurmurHash的高速又通过唯一性检查保证了系统的绝对可靠。在实际编码中重试次数可以设置一个上限比如3次超过则视为系统异常。2.2 存储引擎选型Redis的王者地位短链服务的另一个特点是读多写少且对读取速度和并发能力要求极高。一次跳转请求必须在几十毫秒内完成否则用户体验会急剧下降。关系型数据库如MySQL在这种场景下显得笨重。虽然它能存但每次跳转查询都需要经过连接池、SQL解析、索引查找、磁盘I/O在超高并发下很容易成为瓶颈。而Redis作为内存数据库数据操作在微秒级完成天生就是为了这种高速KV查询而生的。我们的数据模型非常简单Key: 短码如short:a1b2c3。这里加一个前缀short:是为了在Redis中更好地管理命名空间避免与其他业务数据冲突。Value: 原始的长URL字符串。TTL可选: 可以为键设置一个过期时间。对于某些活动链接可能只需要存活几天或几周。Redis的自动过期机制能完美满足需求无需我们自己写清理任务。除了基本的存储Redis还能帮我们轻松实现很多增值功能点击统计每次跳转时使用INCR命令对一个形如clicks:a1b2c3的键进行原子递增即可实现无锁的点击量统计。防刷限流使用INCR和EXPIRE命令可以轻松实现对某个IP或用户在一段时间内生成短链频率的限制。布隆过滤器在查询短码是否存在前可以先通过一个布隆过滤器进行预判如果判断为“肯定不存在”则可以快速返回404减轻Redis的查询压力。Redis自身可以通过RedisBloom模块支持布隆过滤器。因此选择Redis作为核心存储几乎是短链服务架构的最优解。它提供的不仅仅是存储更是一整套高性能的数据结构和原子操作让我们能用最少的代码实现最复杂的功能。3. 实战构建从零搭建一个可用的短链服务理论说得再多不如一行代码。下面我将以Spring Boot为基础演示如何一步步构建这个服务。我会重点说明那些容易踩坑的配置和代码细节。3.1 环境准备与依赖引入首先确保你有一个可用的Redis服务。本地开发可以用Docker快速启动一个docker run -d --name my-redis -p 6379:6379 redis:7-alpine如果你的Redis设置了密码或者运行在别的机器上后续在Spring配置中需要相应调整。创建一个Spring Boot项目在pom.xml中引入必要的依赖dependencies !-- Web 基础 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Redis 集成 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 连接池性能关键 -- dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency !-- MurmurHash 实现这里使用Guava -- dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version31.1-jre/version /dependency !-- 参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies这里特别强调一下commons-pool2。在高并发下如果没有连接池每次操作Redis都新建和关闭连接开销巨大性能会惨不忍睹。Spring Boot的spring-boot-starter-data-redis默认就依赖它但我们需要正确配置。3.2 核心配置Redis连接池与序列化配置文件application.yml是性能调优的第一关spring: redis: host: localhost port: 6379 # 如果有密码 # password: yourpassword database: 0 lettuce: pool: # 连接池最大连接数负表示无限制。根据你的并发量调整太大太小都不好。 max-active: 200 # 连接池最大阻塞等待时间负表示无限制单位毫秒 max-wait: -1ms # 连接池中的最大空闲连接 max-idle: 50 # 连接池中的最小空闲连接 min-idle: 10 # 连接超时时间单位毫秒 timeout: 2000ms # 关闭超时时间 shutdown-timeout: 100ms注意max-active这个参数至关重要它直接关系到我们后面会提到的ERR max number of clients reached错误。设置过小在高并发时请求会排队等待连接导致接口超时设置过大会耗尽Redis服务器的最大客户端连接数限制默认是10000导致新的客户端无法连接。这个值需要根据实际压测结果来调整通常可以先设置为预估的QPS的1.5到2倍。接下来是Redis的序列化配置。Spring Boot默认使用JdkSerializationRedisSerializer它序列化后的值是人类不可读的二进制且在Redis中占用空间大。我们存的是简单的字符串用StringRedisSerializer最合适。Configuration public class RedisConfig { Bean public RedisTemplateString, String redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(connectionFactory); // 使用StringRedisSerializer来序列化和反序列化redis的key和value StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setValueSerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(stringSerializer); template.afterPropertiesSet(); return template; } }3.3 核心服务层生成与重定向逻辑首先我们创建一个工具类来处理MurmurHash和62进制转换import com.google.common.hash.Hashing; import java.nio.charset.StandardCharsets; public class ShortUrlUtils { private static final String BASE62 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz; private static final int SHORT_CODE_LENGTH 6; private static final int MAX_RETRY 3; /** * 生成短码 * param longUrl 长链接 * return 短码如 a1b2c3 */ public static String generateShortCode(String longUrl) { // 1. 计算MurmurHash3 32位哈希值 int hash Hashing.murmur3_32_fixed().hashString(longUrl, StandardCharsets.UTF_8).asInt(); // 取绝对值避免负数 long num hash 0xffffffffL; // 2. 转换为62进制 StringBuilder shortCode new StringBuilder(); while (num 0) { shortCode.insert(0, BASE62.charAt((int)(num % 62))); num num / 62; } // 如果转换后长度不足前面补0用字符‘0’ while (shortCode.length() SHORT_CODE_LENGTH) { shortCode.insert(0, 0); } // 如果超过指定长度取后SHORT_CODE_LENGTH位通常不会发生 if (shortCode.length() SHORT_CODE_LENGTH) { shortCode new StringBuilder(shortCode.substring(shortCode.length() - SHORT_CODE_LENGTH)); } return shortCode.toString(); } }然后编写核心的服务类。这里会用到Redis的SETNX命令在Spring Data Redis中对应opsForValue().setIfAbsent(key, value)方法。Service Slf4j public class ShortUrlService { Autowired private StringRedisTemplate redisTemplate; private static final String KEY_PREFIX short:; private static final String CLICK_PREFIX clicks:; private static final Duration DEFAULT_TTL Duration.ofDays(365); // 默认保存一年 /** * 将长链接转换为短链接 */ public String shorten(String longUrl) { // 输入校验 if (!isValidUrl(longUrl)) { throw new IllegalArgumentException(Invalid URL format); } String shortCode; int retryCount 0; boolean stored false; // 加盐重试机制 while (!stored retryCount MAX_RETRY) { // 生成基础短码 String baseCode ShortUrlUtils.generateShortCode(longUrl); // 如果是重试在短码后追加序号 shortCode retryCount 0 ? baseCode : baseCode _ retryCount; String redisKey KEY_PREFIX shortCode; // 关键步骤使用SETNX原子操作尝试存储 Boolean success redisTemplate.opsForValue().setIfAbsent(redisKey, longUrl, DEFAULT_TTL); if (Boolean.TRUE.equals(success)) { // 存储成功跳出循环 stored true; log.info(Successfully created short code: {} for URL: {}, shortCode, longUrl); } else { // 存储失败可能是碰撞或重复 String existingUrl redisTemplate.opsForValue().get(redisKey); if (longUrl.equals(existingUrl)) { // 是同一个URL直接返回已有短码幂等 log.info(URL already shortened, returning existing code: {}, shortCode); stored true; } else { // 发生哈希碰撞进行重试 retryCount; log.warn(Hash collision detected for base code: {}, retrying... (attempt {}), baseCode, retryCount); } } } if (!stored) { throw new RuntimeException(Failed to generate a unique short code after MAX_RETRY attempts.); } // 返回完整的短链接这里假设你的服务域名为 d.com return https://d.com/ shortCode; } /** * 根据短码获取原始长链接并增加点击计数 */ public String getOriginalUrl(String shortCode) { String redisKey KEY_PREFIX shortCode; String longUrl redisTemplate.opsForValue().get(redisKey); if (longUrl ! null) { // 异步或同步增加点击量。高并发下建议使用异步或消息队列这里简单演示同步操作。 String clickKey CLICK_PREFIX shortCode; redisTemplate.opsForValue().increment(clickKey); // 可以给点击Key也设置一个过期时间与短码Key同步 redisTemplate.expire(clickKey, DEFAULT_TTL); } return longUrl; // 如果为null上层会处理为404 } /** * 获取短码的点击次数 */ public Long getClickCount(String shortCode) { String clickKey CLICK_PREFIX shortCode; String count redisTemplate.opsForValue().get(clickKey); return count null ? 0L : Long.parseLong(count); } private boolean isValidUrl(String url) { // 简单的URL格式校验生产环境建议使用更严格的库 try { new java.net.URL(url).toURI(); return true; } catch (Exception e) { return false; } } }3.4 控制器层提供HTTP接口最后我们创建两个简单的REST接口RestController RequestMapping(/api/short-url) public class ShortUrlController { Autowired private ShortUrlService shortUrlService; PostMapping(/shorten) public ApiResponseString createShortUrl(RequestBody Valid CreateShortUrlRequest request) { String shortUrl shortUrlService.shorten(request.getLongUrl()); return ApiResponse.success(shortUrl); } GetMapping(/{shortCode}) public ResponseEntityVoid redirect(PathVariable String shortCode, HttpServletResponse response) throws IOException { String originalUrl shortUrlService.getOriginalUrl(shortCode); if (originalUrl null) { // 短码不存在返回404 return ResponseEntity.notFound().build(); } // 执行302重定向 response.sendRedirect(originalUrl); return null; // sendRedirect会直接提交响应这里返回null即可 } GetMapping(/{shortCode}/info) public ApiResponseUrlInfo getUrlInfo(PathVariable String shortCode) { String originalUrl shortUrlService.getOriginalUrl(shortCode); if (originalUrl null) { throw new ResourceNotFoundException(Short code not found); } Long clicks shortUrlService.getClickCount(shortCode); UrlInfo info new UrlInfo(shortCode, originalUrl, clicks); return ApiResponse.success(info); } } // 简单的请求和响应对象 Data class CreateShortUrlRequest { NotBlank URL // 使用Hibernate Validator的注解进行校验 private String longUrl; } Data AllArgsConstructor class UrlInfo { private String shortCode; private String longUrl; private Long clickCount; } class ApiResponseT { private int code; private String message; private T data; // 省略构造方法和静态工厂方法 }至此一个具备基本功能的短链服务就搭建完成了。你可以通过POST /api/short-url/shorten生成短链通过GET /api/short-url/{code}进行跳转还能通过GET /api/short-url/{code}/info查看点击量。4. 生产环境避坑从“客户端连接数爆满”说起把服务部署到生产环境后真正的挑战才开始。其中一个非常典型且严重的问题就是Redis的客户端连接数超限。错误信息通常类似于ERR max number of clients reached这个错误意味着你的Redis服务器已经达到了它允许的最大并发客户端连接数新的连接请求被拒绝了。这会导致你的短链服务大面积不可用。4.1 问题根因分析造成这个问题的原因通常不是单一的而是多个因素叠加应用端连接池配置不当这是最常见的原因。就像我们前面在application.yml里配置的max-active如果这个值设置得过大比如1000而你的应用实例又很多比如20个那么理论上最大可能同时建立20 * 1000 20000个连接远超Redis默认的10000限制。即使连接池里的连接大部分是空闲的但它们依然占用着Redis的客户端名额。连接泄露代码中没有正确释放Redis连接。虽然Spring Data Redis和Lettuce等客户端通常能很好地管理连接但在某些复杂事务、异步回调或异常分支中如果处理不当连接可能无法返回到连接池成为“僵尸连接”只增不减最终耗尽资源。Redis服务器配置过低maxclients参数设置得太小。在生产环境这个值需要根据服务器内存和预期负载进行调整。其他服务或客户端占用你的Redis实例可能被多个不同的应用共享其他应用也可能存在连接泄露或配置过大的问题。4.2 诊断与排查步骤当出现连接数告警时可以按照以下步骤排查第一步登录Redis服务器查看当前连接状态。使用redis-cli连接后执行CLIENT LIST命令。这个命令会列出所有连接的客户端信息包括ID、地址、空闲时间、使用的数据库、订阅模式等。输出内容很多我们可以用一些技巧来过滤。# 查看连接总数 redis-cli info clients | grep connected_clients # 输出示例connected_clients:125 # 查看所有客户端列表并计算数量 redis-cli client list | wc -l # 查看空闲时间过长的连接可能泄露 redis-cli client list | grep -E “idle[0-9]{4,}” # 查找空闲超过1000秒的连接第二步分析连接来源。在CLIENT LIST的输出中addr字段显示了客户端的IP和端口。统计哪个IP建立的连接最多基本就能定位问题应用。redis-cli client list | awk ‘{print $2}’ | cut -d -f2 | cut -d: -f1 | sort | uniq -c | sort -nr这个命令会统计每个源IP的连接数并按降序排列。如果发现某个应用服务器的IP连接数异常高比如接近你配置的max-active那么问题很可能出在该应用上。第三步检查应用配置。回顾问题应用的Redis连接池配置特别是max-active、min-idle。在微服务架构下还要确认该应用的实例数量是否意外增加例如Kubernetes中HPA自动扩容了。第四步检查代码。审查所有使用RedisTemplate或LettuceConnection的地方尤其是在Transactional、循环、异步任务中确保连接能在操作完成后被正确释放。注意在Spring的Transactional注解中如果涉及Redis和数据库的混合操作需要特别注意事务管理器的配置避免连接持有时间过长。4.3 解决方案与优化配置合理设置连接池参数这没有银弹必须压测。一个经验性的起始点是max-active: 根据你的应用QPS和Redis操作的平均耗时来估算。例如假设单次Redis操作平均1ms要达到1000 QPS理论上只需要1个连接1000 QPS * 0.001s 1。但为了应对峰值和网络波动可以设置为(QPS * 平均耗时 * 安全系数)。安全系数可以取2-5。例如预估峰值QPS为500平均耗时2ms那么max-active可以设为500 * 0.002 * 3 3。是的可能比你想象的小得多。连接池不是越大越好过大的连接池会导致Redis服务器线程调度开销增大性能反而下降。min-idle: 保持少量空闲连接避免临时创建连接的开销可以设置为max-active的1/5到1/10。max-wait: 设置一个合理的等待时间如200ms当连接池耗尽时新的请求等待一段时间超时则快速失败而不是无限等待导致线程阻塞。配置Redis服务器适当调大maxclients。在redis.conf中修改maxclients 20000同时确保系统的ulimit -n文件描述符限制也足够大因为每个Redis连接也消耗一个文件描述符。设置连接空闲超时与保活在应用配置中可以设置连接的最大空闲时间让连接池自动关闭长时间不用的连接。对于Lettucespring: redis: lettuce: pool: # ... time-between-eviction-runs: 60s # 空闲连接逐出任务的运行周期 min-evictable-idle-time: 10m # 连接最小空闲时间超过则可能被逐出 shutdown-timeout: 100ms此外可以启用TCP KeepAlive让操作系统帮忙检测死连接。实施监控与告警这是最重要的长期措施。监控connected_clients、rejected_connections、used_memory等关键指标。当connected_clients持续达到maxclients的80%时就应该触发告警而不是等到错误发生。代码层面避免在循环内部频繁获取和释放连接尽量使用批量操作命令如mget、mset、pipeline。对于只读操作可以考虑使用只读副本分摊主库的连接压力。通过以上组合拳我们就能将“连接数爆满”这类问题从“救火”状态转变为“可预警、可管控”的常态。这不仅仅是解决一个错误更是构建稳定服务必须掌握的运维能力。
返回列表