写在前面:经过一个月的实习,我逐渐觉得沟通能力和理解能力远比代码能力重要。不论是在科研组会上的哑口无言,还是在工作上对接的七嘴八舌,都暴露出了我语言能力的欠缺。我从初中开始就知道这是我的短板,但没想到有这么短。这也再次给我敲响了警钟,不管你是什么学术或者技术大师,学说话和听说话是最基本的。
需求7:【出海项目】ip限流
注解+aop+lua+redis+滑动窗口限流算法
注解和aop
注解
根据多个时间单位配置限流器,定义了时间枚举类,按照毫秒存储持续时间(因为注解只能用枚举,不能用正常类)。
正常是要传3个值的:次数,时间单位,超时时间(主要是给后面对redis设置expireTime)。这里为了方便超时时间与时间单位一致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| @IpRateLimit(limit = {10,500}, timeUnit = {TimeUnit.MINUTE,TimeUnit.HOUR})
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface IpRateLimit { int[] limit(); TimeUnit[] timeUnit(); }
@Getter @AllArgsConstructor public enum TimeUnit { YEAR(1, "YEAR", 1000L * 60 * 60 * 24 * 30 * 365), MONTH(2, "MONTH", 1000L * 60 * 60 * 24 * 30), DAY(3, "DAY", 1000 * 60 * 60 * 24), HOUR(4, "HOUR", 1000 * 60 * 60), MINUTE(5, "MINUTE", 1000 * 60), SECOND(6, "SECOND", 1000), ; private final int code; private final String name; private final long periodInMills; }
|
aop
校验注解里两个数组长度必须相同,对每一个配置new一个限流器来执行redis脚本。(我在想这里每次限流就new一个开销会不会有点大,但是不能用@service,因为有状态的)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| @Slf4j @Aspect @Component public class IpRateLimiterAspect {
private RedisLuaRateLimiter redisLuaRateLimiter;
@Pointcut("@annotation(com.netease.cowork.sirius.it.data.overseas.server.frame.anno.IpRateLimit)") public void rateLimiter() {}
@Before("rateLimiter() && @annotation(ipLimiter)") public void doBefore(JoinPoint joinPoint, IpRateLimit ipLimiter) throws Throwable { int[] limit = ipLimiter.limit(); TimeUnit[] timeUnits = ipLimiter.timeUnit(); if (limit.length != timeUnits.length){ throw new RuntimeException(); } ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); assert attributes != null; HttpServletRequest request = attributes.getRequest(); String ip = request.getRemoteAddr(); for (int i = 0; i < limit.length; i++) { long periodInMills = timeUnits[i].getPeriodInMills(); redisLuaRateLimiter = new RedisLuaRateLimiter(limit[i], periodInMills, periodInMills,timeUnits[i].getName()); if (!redisLuaRateLimiter.tryAcquired(ip)){ throw new RuntimeException("操作频繁,请稍后重试"); } } } }
|
redis+lua实现滑动窗口限流算法
lua脚本在这里最大的作用就是解决并发问题,且保证原子性。分别说这个几个参数:
- KEYS[1]:String key = prefix + ip。业务名称+ip。因为可能有多个业务都需要限流。
- ARGV[1]:now-periodInMills。时间校验的分界线,往后为需要计算的流量。
- ARGV[2]:now
- ARGV[3]:UUID.randomUUID()真正的value值,随便写一个
- ARGV[4]:expireInMills
- ARGV[5]:limit
基本原理是zset的score存时间戳,ZREMRANGEBYSCORE这个方法会去除0到now-periodInMills的所有元素,剩下的就是要统计的元素。ZCARD用来统计当前元素数量,如果比count小就没有达到限流,ZADD设置value,score和超时时间。如果限流就返回特殊数字。
最后只用判断lua脚本执行是否是1来判断限流。
其他的代码都是java操作lua的方法,核心如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| private static final String REDIS_LIMIT_SCRIPT = "local removedCount = redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])\n" + "local currentCount = redis.call('ZCARD', KEYS[1])\n" + "if currentCount < tonumber(ARGV[5]) then\n" + " redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])\n" + " redis.call('PEXPIRE', KEYS[1], ARGV[4])\n" + " return 1\n" + "else\n" + " return 0\n" + "end";
private static final String REDIS_LIMIT_COUNT_SCRIPT = "local removedCount = redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])\n" + "local currentCount = redis.call('ZCARD', KEYS[1])\n" + "return currentCount";
|
结论是对比海关服务的限流,这种写lua的方法能快三倍,限流速度50ms,他们的150ms。而且配置也很简单。
需求8:【出海项目】sitemap与seo优化
什么是seo?
搜索引擎优化,指的是通过合理手段和一些小技巧提升在谷歌上面的排名。这个出海项目最终要达到的效果是,在谷歌上查询一个公司,我们的界面要比这个公司靠前,从而获取自然流量,有了自然流量就可以变现了。
类似天眼查,每次去查询一个公司的官网,天眼查总能比官网靠前(其实也有可能是百度自家特殊关照)。
什么是sitemap?
sitemap.xml是一个站点地图,相当于给谷歌的爬虫一个指引,告诉他我的网站都有哪些路径需要你来访问的,这个地图一级只能有5w条,而我们的业务规模有6kw,所以采取两级索引,并采用gz进行压缩。
而我做的工作是定时更新这个sitemap并放到前端服务器上给爬虫来爬。
sitemap自底向上构建
给了两张表,底层存公司,顶层存索引。我最初的想法是自底向上构建,每次更新去删除顶层,然后底层分页进行压缩,最后构建顶层。这样会有几个问题:
- 深度分页问题:前面说了数据量最终大于6kw,如此大的数据量到了后面肯定更新不动的。想了半天索引优化最后mentor给否决了。
- 删表问题:数据库不允许传空参数直接删表,而且开销很大。
- seo问题:虽然密集化了表但是可能会造成波动,例如某个公司的前几个失效了,这个公司变动到别的索引之下,可能会对seo产生影响,最好还是固定不变。
sitemap自顶向下构建
自顶向下构建就是直接算好id范围,数据库between一下就可以找到,把索引当作槽来用,填满了就放下一个。优点:
- 没有深度分页问题,直接根据有索引的id查询,效率肯定是比limit高的。
- 一级索引满了就开辟一个新的,效率也高。
缺点:
- 依赖顺序性,稍微中间跨度大了就会有问题。例如首先存了1-100,再从40000-40100。每页100条,此时来了10000-10100就不能保证一级索引的顺序了。
- 一级索引的起始和终点会显得没有意义,中间有大量失效的,且不一定有序。
最终采用了这种方式,es中的数据是不区分新旧的,所以得做一个唯一性校验。除此之外还有一个当前循环的唯一性校验。
- 首先查询当前最大公司的id,通过AtomicInteger解决线程安全问题,从es来的新增数据加入,同时要校验是否是英文公司,并取出首字母。(这里写了一大堆字符串工具)
- 这样做了重复性校验的话即使是失败了也能保证不会重复。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| public void getOpenCompanyIndex(){ try { BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); boolQuery.must(QueryBuilders.existsQuery("summary.formatDomains")); boolQuery.must(QueryBuilders.rangeQuery("emailCount") .gt(1)); boolQuery.must(QueryBuilders.existsQuery("detail.productList")); boolQuery.must(QueryBuilders.existsQuery("overviewDescription")); boolQuery.must(QueryBuilders.existsQuery("detail.sic")); boolQuery.must(QueryBuilders.existsQuery("detail.naics")); boolQuery.must(QueryBuilders.existsQuery("customsItems"));
boolQuery.mustNot(QueryBuilders.termQuery("disable", true)); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder() .query(boolQuery) .fetchSource(new String[]{"name","companyId"}, null) .size(1000); if (openCompanyIndex == null) { log.error("openCompanyIndex is null"); }
SiteMapCompanyEntity maxCompany = siteMapCompanyService.getMaxDataId(); long maxDataId = maxCompany == null ? 1 : maxCompany.getDataId(); Set<String> strings = new HashSet<>(); AtomicInteger count = new AtomicInteger(); openCompanyIndex.scrollSearch(sourceBuilder, searchHits -> { List<SiteMapCompanyEntity> addList = new ArrayList<>(); for (SearchHit searchHit : searchHits) { Map<String, Object> result = searchHit.getSourceAsMap(); SiteMapCompanyEntity siteMapCompanyEntity = new SiteMapCompanyEntity(); String name = result.get("name").toString(); String companyId = result.get("companyId").toString(); if (strings.contains(companyId)) { continue; } strings.add(companyId); LambdaQueryWrapper<SiteMapCompanyEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); lambdaQueryWrapper.eq(SiteMapCompanyEntity::getCompanyId, companyId); if (siteMapCompanyService.count(lambdaQueryWrapper) > 0){ continue; } siteMapCompanyEntity.setCompanyId(companyId); siteMapCompanyEntity.setCompanyName(name); siteMapCompanyEntity.setChangeFreq(ChangeFreqEnum.WEEKLY.getCode()); siteMapCompanyEntity.setDataPriority(1f); siteMapCompanyEntity.setCreateTime(new Date()); siteMapCompanyEntity.setUpdateTime(new Date()); siteMapCompanyEntity.setDataId(maxDataId + count.longValue()); if (!StringUtil.checkEnglishChar(name)){ siteMapCompanyEntity.setNavLetter(null); } else { String temp = StringUtil.removeAllPattern(name); siteMapCompanyEntity.setNavLetter(temp.substring(0, 1).toUpperCase()); } count.getAndIncrement(); addList.add(siteMapCompanyEntity); } siteMapCompanyService.saveBatch(addList); return false; }); log.info(String.valueOf(count)); } catch (Throwable e){ log.error(String.valueOf(e)); } }
|
那怎么做生成的,根据公司的最大值最小值/页面大小得到偏移量。根据这个偏移量去更新和查询,只关注最后一个索引块,如果没满就从这里开始加,更新最大值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| public void generate() { preprocess(); SiteMapCompanyEntity maxCompany = siteMapCompanyService.getMaxDataId(); SiteMapCompanyEntity minCompany = siteMapCompanyService.getMinDataId(); if (maxCompany == null || minCompany == null) { return; } List<CompletableFuture<Void>> futures = new ArrayList<>(); Map<Long,String> siteMap = new HashMap<>(); long minDataId = minCompany.getDataId(); long maxDataId = maxCompany.getDataId(); long pageLength = (long) Math.ceil((double) (maxDataId - minDataId + 1) / PAGE_SIZE); for (long i = 1; i <= pageLength; i++) { long startDataId = minDataId + (i - 1) * PAGE_SIZE; long endDataId = minDataId + i * PAGE_SIZE - 1; long page = i; CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { String gzip = xmlCompanyMapGenerator.generateCompanyMap(startDataId, endDataId,page); if (!StringUtils.isEmpty(gzip)) { siteMap.put(page,gzip); } }, EXECUTOR); futures.add(future); } CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); generateXML(siteMap); long count = siteMapTopService.count(); List<SiteMapTopEntity> insertList = new ArrayList<>(); for (long i = Math.max(1,count); i <= pageLength; i++) { long startDataId = minDataId + (i - 1) * PAGE_SIZE; long endDataId = Math.min(minDataId + i * PAGE_SIZE - 1, maxDataId); SiteMapTopEntity siteMapTopEntity = new SiteMapTopEntity(); siteMapTopEntity.setStartDataId(startDataId); siteMapTopEntity.setEndDataId(endDataId);
siteMapTopEntity.setCreateTime(new Date()); siteMapTopEntity.setUpdateTime(new Date()); if (count != 0 && i == count){ LambdaUpdateWrapper<SiteMapTopEntity> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(SiteMapTopEntity::getName,siteMap.get(i)); siteMapTopService.update(siteMapTopEntity,updateWrapper); continue; } siteMapTopEntity.setName(siteMap.get(i)); insertList.add(siteMapTopEntity); } siteMapTopService.saveBatch(insertList); }
|
第一周首先上了3w数据,未来数据量大了肯定要分库分表的,以后的事情以后再说。
需求9:【出海项目】一些零碎的小需求
【接口】根据首字母和页码返回数据
就是简单的分页问题,但是有一些字符串处理。我写了一大堆字符串工具。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| public PageResp<CompanyUrlResp> getCompanyUrl(String navLetter, Integer pageNum) { LambdaQueryWrapper<SiteMapCompanyEntity> queryWrapper = new LambdaQueryWrapper<>(); if (StringUtils.isNotEmpty(navLetter)) { if (navLetter.equals("Other")) { queryWrapper.isNull(SiteMapCompanyEntity::getNavLetter); } else if (!navLetter.matches("^[a-zA-Z]$")) { throw new BizException(BizError.PARAM_ERR.getName(), BizError.PARAM_ERR.getCode()); } else { queryWrapper.eq(SiteMapCompanyEntity::getNavLetter, navLetter.toUpperCase()); } } else { throw new BizException(BizError.PARAM_ERR.getName(), BizError.PARAM_ERR.getCode()); } queryWrapper.eq(SiteMapCompanyEntity::getStatus, CompanyStatus.ACTIVE.getCode());
IPage<SiteMapCompanyEntity> page = new Page<>(pageNum, COMPANY_URL_PAGE_SIZE); IPage<SiteMapCompanyEntity> userPage = siteMapCompanyService.page(page, queryWrapper); List<SiteMapCompanyEntity> userList = userPage.getRecords(); PageResp<CompanyUrlResp> pageResp = new PageResp<>(); pageResp.setPageNo(pageNum); pageResp.setPageSize(COMPANY_URL_PAGE_SIZE); pageResp.setTotalPage(userPage.getPages()); pageResp.setTotalSize(userPage.getTotal()); pageResp.setContent(userList.stream().map(item -> { CompanyUrlResp companyUrlResp = new CompanyUrlResp(); companyUrlResp.setCompanyId(item.getCompanyId()); companyUrlResp.setCompanyName(StringUtil.firstLetterToUpperCaseByBlank(item.getCompanyName())); if (!StringUtil.checkEnglishChar(item.getCompanyName())){ companyUrlResp.setCompanyFormatName("Non-English-Company"); } else { String temp = StringUtil.formatCompanyNameToUrl(item.getCompanyName()); companyUrlResp.setCompanyFormatName(temp); } return companyUrlResp; }).collect(Collectors.toList())); return pageResp; }
|
【接口】公司详情获取
修改了mentor的一点逻辑,做了一点校验:
- 因为查的是全球搜的数据,有可能未公开,校验只有开启的公司才能查询。
- 如果没有登录,domain隐蔽,这里看threadlocal有没有东西就好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| public CompanyBaseVO base(String companyId) { if (!siteMapCompanyService.isOpeningCompany(companyId)) { throw new BizException(BizError.NON_OPENING_COMPANY.getName(), BizError.NON_OPENING_COMPANY.getCode()); } try{ String url = host+"/api/auth/global/overseas/base"; Map<String, String> map = new HashMap<>(); map.put("companyId", companyId); map.put("appKey", appKey); map.put("appSecret", appSecret); map.put("timestamp", String.valueOf(System.currentTimeMillis())); map = OpenApiUtil.packageSign(map, appKey, appSecret); String resut = OkHttpUtil.get(url, map); if (StringUtils.isNotBlank(resut)) { resut = ImagePathConvert.convert(resut); JSONObject jsonObject = JSON.parseObject(resut); String dataResult = jsonObject.getString("data"); return loginAuth(JSON.parseObject(dataResult, CompanyBaseVO.class)); } return null; }catch (Exception e){ log.info("demo error", e); return null; } } public CompanyBaseVO loginAuth(CompanyBaseVO companyBaseVO){ UserInfoToken userInfoToken = UserContext.getContent().getUserInfoToken(); if (userInfoToken == null) { companyBaseVO.setDomain("*****"); } return companyBaseVO; }
|