前段时间,公司项目出现一个问题,程序内部有一些接口提供给第三方调用,虽然这些接口做了鉴权,但是没有做任何限流措施。结果第三方疯狂的调用这些接口,导致系统处于崩溃边缘。后来经过讨论后,决定采用sentinel来做流量治理。毫无疑问,这个雷又要我来埋了。虽然之前有了解过sentinel,但也仅仅是皮毛,并没有真正在项目中使用过。所以借助这次机会,我也对sentinel进行了一次详细的学习。
通过阅读官方文档和源码,发现默认情况下sentinel结合dashboard的规则都是存储在内存中的,那也就意味着,如果项目重启,所有的规则都会被抹掉。那这种肯定是不行的,项目每启动一次,都需要配置一遍规则,所以规则需要进行持久化。sentinel是alibaba开源的项目,官方更推荐使用nacos做规则配置持久化,但是为了限流多搭一套nacos环境,我是比较排斥的,所以基于公司现有情况,准备出两种持久化方案,一种是数据库,一种是Redis。
如果选择数据库,那就需要建表,写sql语句,应用服务还需要监听规则的变化等。但是redis就会便捷很多,只需要使用缓存和发布订阅功能就可以解决问题。详细流程可以分为4个步骤:
- dashboard将规则持久化到redis,并通过发布订阅功能通知应用服务更新规则
- 应用服务接收到更新规则的消息后,从缓存中读取最新规则并更新
- dashboard重启后可以从redis中读取数据
- 应用服务重启后可以从redis中读取规则数据
流程梳理完成后,就开始着手编码吧。
自定义数据源
首先需要定义好监听的规则,即每种规则需要有不同的处理方式,需要存储在不同的key下。所以在定义数据源前,先定义一个枚举类,用来区分消息类型,缓存的key值。比如redis监听到的消息是flow,那就代表限流规则更新了,那么就从redis中拉取key为sentinel_flow_simp_{应用名}的缓存。
public enum SentinelFunctionEnum {
FLOW("flow","sentinel_flow_simp_","限流规则"),
AUTH("auth","sentinel_auth_simp_","授权规则"),
SYSTEM("system","sentinel_system_simp_","系统规则"),
DEGRADE("degrade","sentinel_degrade_simp_","熔断规则"),
;
public String function;
public String key;
public String desc;
SentinelFunctionEnum(String function,String key,String desc){
this.function = function;
this.key = key;
this.desc = desc;
}
public String getFunction() {
return function;
}
public String getKey() {
return key;
}
public String getDesc() {
return desc;
}
}
枚举类定义完成后,开始自定义数据源,sentinel提供了一个抽象类AbstractDataSource
,通过继承这个父类,并重写readSource
方法,即可自定义数据源。
/**
* 扩展的数据源需要继承AbstractDataSource类
* @param <T>
*/
public class SentinelRedisDataSource<T> extends AbstractDataSource<String,T> {
public RedisTemplate redisTemplate;
// redis订阅的channel,建议设置为应用名
// 一个应用服务对应一个channel,在通过消息内容区分不同的规则
// 可以理解为rocketmq中的Topic和tag的关系
public String channel;
// 定义的枚举类
public SentinelFunctionEnum sentinelFunctionEnum;
public SentinelRedisDataSource(RedisTemplate redisTemplate, String channel, SentinelFunctionEnum sentinelFunctionEnum,Converter<String, T> parser) {
super(parser);
this.redisTemplate = redisTemplate;
this.channel=channel;
this.sentinelFunctionEnum = sentinelFunctionEnum;
}
/**
* 从redis中获取规则
* @return
* @throws Exception
*/
@Override
public String readSource() throws Exception {
// 读取规则的key
Object sentinelJob = redisTemplate.opsForValue().get(sentinelFunctionEnum.getKey()+channel);
return String.valueOf(sentinelJob);
}
/**
* 关闭数据源连接(这里使用的是redisTemplate,不用关闭)
* @throws Exception
*/
@Override
public void close() throws Exception {
}
}
加载数据到规则管理器
数据源定义完成后,需要在项目启动的时候注册数据源并且加载上次的规则到管理器中。需要注意的是,每种规则的管理器是不一样的。流控规则管理器对应FlowRuleManager,授权规则管理器对应AuthorityRuleManager,系统规则对应SystemRuleManager等。
/**
* 项目启动后需要注册数据源并恢复原有流控规则
*/
@PostConstruct
public void ruleManager() {
try {
// 将扩展的redis数据源注册到流控规则管理中
// 1. 流控规则
ReadableDataSource<String, List<FlowRule>> flowRuleDataSource
= new SentinelRedisDataSource(redisTemplate, channel, SentinelFunctionEnum.FLOW, source -> JSON.parseObject((String) source, new TypeReference<List<FlowRule>>() {
}));
FlowRuleManager.register2Property(flowRuleDataSource.getProperty());
// 2. 授权规则
ReadableDataSource<String, List<AuthorityRule>> authRuleDataSource
= new SentinelRedisDataSource(redisTemplate, channel, SentinelFunctionEnum.AUTH, source -> JSON.parseObject((String) source, new TypeReference<List<AuthorityRule>>() {
}));
AuthorityRuleManager.register2Property(authRuleDataSource.getProperty());
// 3. 系统规则
ReadableDataSource<String, List<SystemRule>> systemRuleDataSource
= new SentinelRedisDataSource(redisTemplate, channel, SentinelFunctionEnum.SYSTEM, source -> JSON.parseObject((String) source, new TypeReference<List<SystemRule>>() {
}));
SystemRuleManager.register2Property(systemRuleDataSource.getProperty());
// 4. 熔断规则
ReadableDataSource<String, List<DegradeRule>> degRuleDataSource
= new SentinelRedisDataSource(redisTemplate, channel, SentinelFunctionEnum.DEGRADE, source -> JSON.parseObject((String) source, new TypeReference<List<DegradeRule>>() {
}));
DegradeRuleManager.register2Property(degRuleDataSource.getProperty());
// 1.1 恢复原有流控规则
Object flowJobObj = redisTemplate.opsForValue().get(SentinelFunctionEnum.FLOW.getKey() + channel);
if (flowJobObj != null) {
List<FlowRule> flowRules = JSONObject.parseArray(String.valueOf(flowJobObj), FlowRule.class);
WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(flowRules));
FlowRuleManager.loadRules(flowRules);
}
// 2.1 恢复原有的授权规则
Object authObj = redisTemplate.opsForValue().get(SentinelFunctionEnum.AUTH.getKey() + channel);
if (authObj != null) {
List<AuthorityRule> authorityRules = JSONObject.parseArray(String.valueOf(authObj), AuthorityRule.class);
WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(authorityRules));
AuthorityRuleManager.loadRules(authorityRules);
}
// 3.1 恢复原有的系统规则
Object systemObj = redisTemplate.opsForValue().get(SentinelFunctionEnum.SYSTEM.getKey() + channel);
if (systemObj != null) {
List<SystemRule> systemRules = JSONObject.parseArray(String.valueOf(systemObj), SystemRule.class);
SystemRuleManager.loadRules(systemRules);
}
// 4.1 恢复原有的熔断规则
Object degJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.DEGRADE.getKey() + channel);
if (degJob != null) {
List<DegradeRule> degradeRules = JSONObject.parseArray(String.valueOf(degJob), DegradeRule.class);
WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(degradeRules));
DegradeRuleManager.loadRules(degradeRules);
}
} catch (Exception e) {
log.error("【sentinel初始化异常】");
e.printStackTrace();
}
}
动态刷新规则
数据源注册完成后,需要监听dashboard服务发布的消息,从缓存中读取最新的规则数据,并刷新规则内容数据。首先需要订阅消息,监听规则内容的变化。
/**
* 订阅Topic
*
* @param connectionFactory
* @param listenerAdapter
* @return
*/
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
//订阅通道(项目名)
container.addMessageListener(listenerAdapter, new PatternTopic(channel));
return container;
}
/**
* 监听消息
*
* @param receiver 自定义的监听类
* @return
*/
@Bean
public MessageListenerAdapter listenerAdapter(RedisReceiver receiver) {
// handel为消息处理的方法
return new MessageListenerAdapter(receiver, "handel");
}
监听到消息后,需要根据不同的消息类型,刷新对应的规则数据。RestfulUrlCleaner.create
在下文会提及到,这里暂不做描述。
@Component
public class RedisReceiver {
@Autowired
private RedisTemplate redisTemplate;
@Value("${spring.application.name:open}")
private String channel;
/**
* 监听消息的处理方法 sentinel-dashboard更新规则时,会通过message来告知哪一项规则发生了变动
*
* @param message 消息类型 -> SentinelFunctionEnum
*/
public void handel(String message) {
// 限流的消息
if (StrUtil.contains(message, SentinelFunctionEnum.FLOW.getFunction())) {
Object flowJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.FLOW.getKey() + channel);
if (flowJob != null) {
List<FlowRule> flowRules = JSONObject.parseArray(String.valueOf(flowJob), FlowRule.class);
WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(flowRules));
// 刷新规则
FlowRuleManager.loadRules(JSONObject.parseArray(String.valueOf(flowJob), FlowRule.class));
}
}
// 授权的消息
if (StrUtil.contains(message, SentinelFunctionEnum.AUTH.getFunction())) {
Object authJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.AUTH.getKey() + channel);
if (authJob != null) {
List<AuthorityRule> authorityRules = JSONObject.parseArray(String.valueOf(authJob), AuthorityRule.class);
WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(authorityRules));
AuthorityRuleManager.loadRules(authorityRules);
}
}
// 系统规则消息
if (StrUtil.contains(message, SentinelFunctionEnum.SYSTEM.getFunction())) {
Object sysJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.SYSTEM.getKey() + channel);
if (sysJob != null) {
List<SystemRule> systemRules = JSONObject.parseArray(String.valueOf(sysJob), SystemRule.class);
SystemRuleManager.loadRules(systemRules);
}
}
// 熔断规则消息
if(StrUtil.contains(message, SentinelFunctionEnum.DEGRADE.getFunction())){
Object degJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.DEGRADE.getKey() + channel);
if(degJob!=null){
List<DegradeRule> degRules = JSONObject.parseArray(String.valueOf(degJob), DegradeRule.class);
WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(degRules));
DegradeRuleManager.loadRules(degRules);
}
}
}
}
sentinel-dashboard源码修改
上面应用服务的代码已经撸完了,现在开始改造dashboard的代码,原代码中规则变动的通知都是通过http方式进行的,那么这就需要改造成redis来通知的方式。默认情况下dashboard提供了两个接口类来支持我们自定义改造,其分别是DynamicRulePublisher
(推送规则)和DynamicRuleProvider
(拉取规则)。
在改造前,需要定义好redis的key,需要注意的是一种规则需要定义两个key,其中SIMP_KEY是需要和应用服务中的对应,另一个key则是留给当前dashoard回显数据用。同一种规则为什么要定义两个key呢?这是因为dashboard服务和应用服务规则对象是不一致的,以限流规则为例: dashboard服务中的规则对象是FlowRuleEntity,应用服务的规则对象是FlowRule。
定义缓存key
public class RuleConstants {
// ======= 限流相关的key ========
public static final String SENTINEL_FLOW_KEY = "sentinel_flow_";
public static final String SENTINEL_FLOW_RULE_SIMP_KEY = "sentinel_flow_simp_";
// ====== 授权相关的key ========
public static final String SENTINEL_AUTH_KEY = "sentinel_auth_";
public static final String SENTINEL_AUTH_RULE_SIMP_KEY = "sentinel_auth_simp_";
// ====== 系统规则相关的key ========
public static final String SENTINEL_SYSTEM_KEY = "sentinel_system_";
public static final String SENTINEL_SYSTEM_RULE_SIMP_KEY = "sentinel_system_simp_";
// ====== 熔断规则相关的key ========
public static final String SENTINEL_DEGRADE_KEY = "sentinel_degrade_";
public static final String SENTINEL_DEGRADE_RULE_SIMP_KEY ="sentinel_degrade_simp_";
}
因为各种规则中间除对象不同外,Publisher和Provider逻辑基本相同,所以这里就以限流规则为例来定义相关实现类。
定义Publisher
流程逻辑是将最新的规则数据转化为FlowRule对象,存储到redis中并发布通知,告知订阅者拉取最新的规则数据。
/**
* 流控Publisher
*/
@Component("redisFlowRuleApiPublisher")
public class RedisFlowRuleApiPublisher implements DynamicRulePublisher<List<FlowRuleEntity>> {
@Resource
public RedisTemplate redisTemplate;
/**
* 更新规则,并通知客户端刷新规则
* @param app app name
* @param rules list of rules to push
* @throws Exception
*/
@Override
public void publish(String app, List<FlowRuleEntity> rules) throws Exception {
if (StringUtil.isBlank(app)) {
return;
}
if (rules == null) {
rules = new ArrayList<>();
}
String s = JSON.toJSONString(rules);
List<FlowRule> flowRuleList = rules.stream().map(FlowRuleEntity::toRule).collect(Collectors.toList());
String simple = JSON.toJSONString(flowRuleList);
redisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> execute(RedisOperations operations) throws DataAccessException {
operations.multi();
operations.opsForValue().set(RuleConstants.SENTINEL_FLOW_KEY + app,s);
operations.opsForValue().set(RuleConstants.SENTINEL_FLOW_RULE_SIMP_KEY+ app,simple);
// 通知订阅者,限流规则更新了
operations.convertAndSend(app,"flow");
return operations.exec();
}
});
}
}
定义Provider
除了应用服务需要规则数据外,dashboard本身也是需要在页面上会先配置的规则数据的。那这个需要从redis中读取数据。Provider做的就是这个工作。
/**
* 流控Provider
*/
@Component("redisFlowRuleApiProvider")
public class RedisFlowRuleApiProvider implements DynamicRuleProvider<List<FlowRuleEntity>> {
@Resource
public RedisTemplate redisTemplate;
/**
* 从redis中读取规则
* @param appName
* @return
* @throws Exception
*/
@Override
public List<FlowRuleEntity> getRules(String appName) throws Exception {
if (StringUtil.isBlank(appName)) {
return new ArrayList<>();
}
Object json = redisTemplate.opsForValue().get(RuleConstants.SENTINEL_FLOW_KEY + appName);
if(json!=null){
List<FlowRuleEntity> flowRuleEntities = JSON.parseArray(String.valueOf(json), FlowRuleEntity.class);
return flowRuleEntities;
}
return new ArrayList<>();
}
}
修改controller
Provider和Publisher已经改造完成,接下来就需要在controller中调用这两个方法。并在 v2 的 controller 中通过 @Qualifier
注解替换相应的 bean 即可实现应用维度推送。
列表接口
@GetMapping("/rules")
@AuthAction(PrivilegeType.READ_RULE)
public Result<List<FlowRuleEntity>> apiQueryMachineRules(@RequestParam String app) {
if (StringUtil.isEmpty(app)) {
return Result.ofFail(-1, "app can't be null or empty");
}
try {
// 从redis中读取规则数据,用于列表展示
List<FlowRuleEntity> rules = ruleProvider.getRules(app);
if (rules != null && !rules.isEmpty()) {
for (FlowRuleEntity entity : rules) {
entity.setApp(app);
if (entity.getClusterConfig() != null && entity.getClusterConfig().getFlowId() != null) {
entity.setId(entity.getClusterConfig().getFlowId());
}
}
}
//将规则数据暂存正内存中,便于后续进行crud操作
rules = repository.saveAll(rules);
return Result.ofSuccess(rules);
} catch (Throwable throwable) {
logger.error("Error when querying flow rules", throwable);
return Result.ofThrowable(-1, throwable);
}
}
新增规则接口
@PostMapping("/rule")
@AuthAction(value = AuthService.PrivilegeType.WRITE_RULE)
public Result<FlowRuleEntity> apiAddFlowRule(@RequestBody FlowRuleEntity entity) {
Result<FlowRuleEntity> checkResult = checkEntityInternal(entity);
if (checkResult != null) {
return checkResult;
}
entity.setId(null);
Date date = new Date();
entity.setGmtCreate(date);
entity.setGmtModified(date);
entity.setLimitApp(entity.getLimitApp().trim());
entity.setResource(entity.getResource().trim());
try {
entity = repository.save(entity);
publishRules(entity.getApp());
} catch (Throwable throwable) {
logger.error("Failed to add flow rule", throwable);
return Result.ofThrowable(-1, throwable);
}
return Result.ofSuccess(entity);
}
// 实现推送消息的方法
private void publishRules(/*@NonNull*/ String app) throws Exception {
List<FlowRuleEntity> rules = repository.findAllByApp(app);
rulePublisher.publish(app, rules);
}
到这里持久化操作就算完成了,只需要启动项目就可以正常操作啦,即使应用服务部署多个节点也没关系,因为用的是发布订阅,所以所有节点都会进行同步更新操作。
在开发过程中遇到了一些问题,在这里也一并记录一下。分别是限制来源导致OOM和数据乱码问题,以及接口识别错误的问题。
请求来源限制
若 Web 应用使用了 Sentinel Web Filter,并希望对 HTTP 请求按照来源限流,则可以自己实现 RequestOriginParser
接口从 HTTP 请求中解析 origin 并注册至 WebCallbackManager
中。
WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(degRules));
但是当这段逻辑部署到测试环境的时候,发现数据库中出现了乱码,经过一系列的排查最终发现,只要请求经过这个方法,就会导致参数乱码,所以需要做一次转码操作。更悲催的事情来了,经过一系列的测试后,决定上线了,结果第二天生产环境OOM了,那这不用想了,肯定是sentinel的问题。有一次经过一系列的排查定位,结果发现还是在这个方法中,最终发现到每个Origin都会创建一个StatisticNode对象并放在内存中,并且不会释放,最终导致OOM。
@Component
public class SentinelRequestOriginParser implements RequestOriginParser {
@Autowired
private SessionRepository<? extends Session> repository;
/**
* 区分来源 当前来源数据为请求用户的用户名
* @return
*/
@Override
public String parseOrigin(HttpServletRequest httpServletRequest) {
// 先设置字符集,否则经过该方法后请求字段会乱码
try {
httpServletRequest.setCharacterEncoding("UTF-8");
} catch (UnsupportedEncodingException e) {
return "";
}
String username = "";
Map<String, String[]> parameterMap = httpServletRequest.getParameterMap();
String token = httpServletRequest.getParameter(ApplicationConstants.ACCESS_TOKEN_ATTRIBUTE_NAME);
if (token != null) {
Session session = repository.getSession(token);
if (session != null) {
/**
* Origin不能太多 ,否则会打满内存 ClusterNode.getOrCreateOriginNode 方法中会判断Origin ,
* 每个Origin都会创建一个StatisticNode对象并放在内存中
* {@link com.alibaba.csp.sentinel.node.ClusterNode.getOrCreateOriginNode}
*/
List<Integer> needLimitUserType = CollUtil.newArrayList(UserTypeEnum.JKZH.getCode());
User user = session.getAttribute(ApplicationConstants.JKZH_USER_IN_SESSION);
if(user!=null){
UserTypeEnum type = user.getType();
if(type!=null && needLimitUserType.contains(type.getCode())){
username = user.getUsername();
}
}
}
}
return username;
}
}
接口识别错误
sentinel对接口的识别并不是完美的,对于接口中带有参数则无法准确识别,比如/api/user/{userId}。这个时候sentinel会把每个userId都识别成一个独立的接口,导致限流失败,所以需要实现UrlCleaner类,自己匹配这种接口
public class RestfulUrlCleaner implements UrlCleaner {
private List<RestfulPattern> patterns = new ArrayList<>();
private RestfulUrlCleaner() {
}
/**
* 根据流量控制规则创建与之匹配的RestfulUrlCleaner
* @param rules 流量控制规则
* @return RestfulUrlCleaner
*/
public static RestfulUrlCleaner create(List<?> rules) {
RestfulUrlCleaner cleaner = new RestfulUrlCleaner();
cleaner.patterns.clear();
if (rules == null || rules.size() == 0) {
return cleaner;
}
Pattern p = Pattern.compile("\{[^\}]+\}");
for (Object rule : rules) {
String resource = null;
if(rule instanceof FlowRule){
resource =((FlowRule) rule).getResource();
}else if(rule instanceof AuthorityRule){
resource =((AuthorityRule) rule).getResource();
}else if(rule instanceof DegradeRule){
resource =((DegradeRule) rule).getResource();
}
Matcher m = p.matcher(resource);
//如果发现类似{xxx}的结构,断定其为RESTful接口
if (m.find()) {
cleaner.patterns.add(new RestfulPattern(Pattern.compile(m.replaceAll("\\S+?")), resource));
}
}
//根据正则表达式重新排序
Collections.sort(cleaner.patterns);
return cleaner;
}
@Override
public String clean(String s) {
for (RestfulPattern pattern : patterns) {
if (pattern.getPattern().matcher(s).matches()) {
return pattern.getRealResource();
}
}
return s;
}
}
最后,如果项目中引用新技术,建议一定要仔细阅读官方文档,避免踩坑,能上云就上云,能让别人做就别自己做😢😢😢😢。