如何锁定同IP的外部API调用,避免多线程重复消耗配额?
解决方案:利用并发字典实现同一IP的请求合并
要解决同一IP多线程重复调用外部API的问题,核心思路是让同一个IP的所有并发请求共享同一个API调用任务,只有第一个发起请求的线程去调用外部API,其他线程等待该任务完成后直接复用结果。具体实现可以借助ConcurrentDictionary来缓存正在执行的API请求任务,以下是修改后的完整代码和关键说明:
修改后的服务代码
// Registered as Singleton in ASP.NET Core DI public class IPInfoService( IServiceProvider serviceProvider, IConfiguration configuration, IHttpClientFactory httpClientFactory) { private readonly string _apiKey = configuration["MyApiKey"] ?? ""; private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); // 缓存正在进行的API请求任务,键为IP地址 private readonly ConcurrentDictionary<string, Task<IPInfoDTO>> _pendingApiRequests = new(); public async Task<IPInfoDTO> GetIPInfoAsync(string ipString) { // 第一步:检查本地数据库是否有有效记录 using var scope = serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService<IpInfoDbContext>(); var dbRow = await dbContext.IpInfos .FirstOrDefaultAsync(x => x.IpAddress == ipString && x.ExpiresAt > DateTime.UtcNow); if (dbRow != null) { return MapToDTO(dbRow); } // 第二步:尝试获取或创建当前IP的API请求任务 var apiTask = _pendingApiRequests.GetOrAdd(ipString, async (ip) => { try { // 双重检查数据库:防止在任务创建期间已有其他线程完成API调用并写入数据 using var taskScope = serviceProvider.CreateScope(); var taskDbContext = taskScope.ServiceProvider.GetRequiredService<IpInfoDbContext>(); var existingRow = await taskDbContext.IpInfos .FirstOrDefaultAsync(x => x.IpAddress == ip && x.ExpiresAt > DateTime.UtcNow); if (existingRow != null) { return MapToDTO(existingRow); } // 调用外部API var response = await _httpClient.GetAsync($"https://your-external-api.com/ip/{ip}?key={_apiKey}"); response.EnsureSuccessStatusCode(); var ipInfo = await response.Content.ReadFromJsonAsync<IPInfoDTO>() ?? throw new InvalidOperationException("API返回空数据"); // 将结果写入数据库 taskDbContext.IpInfos.Add(new IpInfoEntity { IpAddress = ip, // 映射DTO字段到实体 Country = ipInfo.Country, City = ipInfo.City, ExpiresAt = DateTime.UtcNow.AddMonths(1) }); await taskDbContext.SaveChangesAsync(); return ipInfo; } finally { // 任务完成(成功/失败)后移除缓存,避免内存泄漏 _pendingApiRequests.TryRemove(ip, out _); } }); // 等待API任务完成,返回结果 return await apiTask; } // 辅助方法:将数据库实体映射为DTO private IPInfoDTO MapToDTO(IpInfoEntity entity) { return new IPInfoDTO { IpAddress = entity.IpAddress, Country = entity.Country, City = entity.City // 补充其他字段 }; } }
关键实现说明
- ConcurrentDictionary原子操作:
GetOrAdd是线程安全的原子方法,确保同一个IP只会创建一个API请求任务,后续线程直接复用已有的任务,完全避免重复调用外部API。 - 双重数据库检查:在任务内部再次查询数据库,防止在第一个线程创建任务的短暂窗口中,其他线程已经完成了API调用并写入数据,进一步避免不必要的API请求。
- 独立Scope管理DbContext:单例服务中不能共享DbContext,因此在异步任务内部单独创建Scope获取DbContext,保证DbContext的线程安全性。
- 任务清理:通过
finally块在任务完成后移除缓存的任务,避免字典内存泄漏,同时让后续失败的IP请求可以重新发起API调用。 - HttpClient优化:改用
IHttpClientFactory创建HttpClient,替代直接new的方式,避免长期运行导致的Socket资源耗尽问题。
额外注意事项
- 可以根据需求为API调用添加重试逻辑(比如用Polly库),应对外部API的临时故障。
- 如果API调用失败,所有等待该任务的线程都会收到相同的异常,你可以在catch块中处理异常,返回默认值或自定义错误信息。
内容的提问来源于stack exchange,提问作者Parsa99
相关产品推荐
相关产品推荐

