微服务限流容错降级Sentinel实战

点赞再看,养成习惯,搜一搜【一角钱技术】关注更多原创技术文章。本文 GitHub org_hejianhui/JavaStudy 已收录,有我的系列文章。

一、什么是雪崩效应?


业务场景,高并发调用

  1. 正常情况下,微服务A B C D 都是正常的。
  2. 随着时间推移,在某一个时间点 微服务A突然挂了,此时的微服务B 还在疯狂的调用微服务A,由于A已经挂了,所以B调用A必须等待服务调用超时。而我们知道每次B -> A 的适合B都会去创建线程(而线程由计算机的资源,比如cpu、内存等)。由于是高并发场景,B 就会阻塞大量的线程。那边B所在的机器就会去创建线程,但是计算机资源是有限的,最后B的服务器就会宕机。(说白了微服务B 活生生的被猪队友微服务A给拖死了
  3. 由于微服务A这个猪队友活生生的把微服务B给拖死了,导致微服务B也宕机了,然后也会导致微服务 C D 出现类似的情况,最终我们的猪队友A成功的把微服务 B C D 都拖死了。这种情况也叫做服务雪崩。也有一个专业术语(cascading failures)级联故障。

二、容错三板斧

2.1 超时

简单来说就是超时机制,配置以下超时时间,假如1秒——每次请求在1秒内必须返回,否则到点就把线程掐死,释放资源!

思路:一旦超时,就释放资源。由于释放资源速度较快,应用就不会那么容易被拖死。

代码演示:(针对调用方处理)

// 第一步:设置RestTemplate的超时时间
@Configuration
public class WebConfig {

    @Bean
    public RestTemplate restTemplate() {
        //设置restTemplate的超时时间
        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
        requestFactory.setReadTimeout(1000);
        requestFactory.setConnectTimeout(1000);
        RestTemplate restTemplate = new RestTemplate(requestFactory);
        return restTemplate;
    }
}

// 第二步:进行超时异常处理
try{
    ResponseEntity<ProductInfo> responseEntity= restTemplate.getForEntity(uri+orderInfo.getProductNo(), ProductInfo.class);
    productInfo = responseEntity.getBody();
}catch (Exception e) {
    log.info("调用超时");
    throw new RuntimeException("调用超时");
}


// 设置全局异常处理
@ControllerAdvice
public class NiuhExceptionHandler {

    @ExceptionHandler(value = {RuntimeException.class})
    @ResponseBody
    public Object dealBizException() {
        OrderVo orderVo = new OrderVo();
        orderVo.setOrderNo("-1");
        orderVo.setUserName("容错用户");
        return orderVo;
    }
}

2.2 舱壁隔离模式

有兴趣可以先了解一下船舱构造——一般来说,现代的轮船都会分很多舱室,舱室直接用钢板焊死,彼此隔离。这样即使有某个/某些船舱进水,也不会营销其它舱室,浮力够,船不会沉。

代码中的舱壁隔离(线程池隔离模式)

M类使用线程池1,N类使用线程池2,彼此的线程池不同,并且为每个类分配的线程池大小,例如 coreSIze=10。

举例子:M类调用B服务,N类调用C服务,如果M类和N类使用相同的线程池,那么如果B服务挂了,N类调用B服务的接口并发又很高,你又没有任何保护措施,你的服务就很可能被M类拖死。而如果M类有自己的线程池,N类也有自己的线程池,如果B服务挂了,M类顶多是将自己的线程池占满,不会影响N类的线程池——于是N类依然能正常工作。

思路:不把鸡蛋放在一个篮子里,你有你的线程池,我有我的线程池,你的线程池满类和我也没关系,你挂了也和我也没关系。

2.3 断路器模式

现实世界的断路器大家肯定都很了解,每个人家里都会有断路器。断路器实时监控电路的情况,如果发现电路电流异常,就会跳闸,从而防止电路被烧毁。

软件世界的断路器可以这样理解:实时监测应用,如果发现在一定时间内失败次数/失败率达到一定阀值,就“跳闸”,断路器打开——次数,请求直接返回,而不去调用原本调用的逻辑。

跳闸一段时间后(例如15秒),断路器会进入半开状态,这是一个瞬间态,此时允许一个请求调用该调的逻辑,如果成功,则断路器关闭,应用正常调用;如果调用依然不成功,断路器继续回到打开状态,过段时间再进入半开状态尝试——通过“跳闸”,应用可以保护自己,而且避免资源浪费;而通过半开的设计,可以实现应用的“自我修复”

三、Sentinel 流量控制、容错、降级

3.1 什么是Sentinel?

A lightweight powerful flow control component enabling reliability and monitoring for microservices.(轻量级的流量控制、熔断降级 Java 库)
github官网地址:https://github.com/alibaba/Sentinel
wiki:https://github.com/alibaba/Sentinel/wiki/

Hystrix 在 Sentinel 面前就是弟弟

Sentinel的初体验

niuh04-ms-alibaba-sentinel-helloworld

V1版本

  • 第一步:添加依赖包
<!--导入Sentinel的相关jar包-->
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-core</artifactId>
    <version>1.7.1</version>
</dependency>
  • 第二步:controller
@RestController
@Slf4j
public class HelloWorldSentinelController {

    @Autowired
    private BusiServiceImpl busiService;

    /**
     * 初始化流控规则
     */
    @PostConstruct
    public void init() {

        List<FlowRule> flowRules = new ArrayList<>();

        /**
         * 定义 helloSentinelV1 受保护的资源的规则
         */
        //创建流控规则对象
        FlowRule flowRule = new FlowRule();
        //设置流控规则 QPS
        flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        //设置受保护的资源
        flowRule.setResource("helloSentinelV1");
        //设置受保护的资源的阈值
        flowRule.setCount(1);

        flowRules.add(flowRule);

        //加载配置好的规则
        FlowRuleManager.loadRules(flowRules);
    }


    /**
     * 频繁请求接口 http://localhost:8080/helloSentinelV1
     * 这种做法的缺点:
     * 1)业务侵入性很大,需要在你的controoler中写入 非业务代码..
     * 2)配置不灵活 若需要添加新的受保护资源 需要手动添加 init方法来添加流控规则
     * @return
     */
    @RequestMapping("/helloSentinelV1")
    public String testHelloSentinelV1() {

        Entry entity =null;
        //关联受保护的资源
        try {
            entity = SphU.entry("helloSentinelV1");
            //开始执行 自己的业务方法
            busiService.doBusi();
            //结束执行自己的业务方法
        } catch (BlockException e) {
            log.info("testHelloSentinelV1方法被流控了");
            return "testHelloSentinelV1方法被流控了";
        }finally {
            if(entity!=null) {
                entity.exit();
            }
        }
        return "OK";
    }
}

测试效果:http://localhost:8080/helloSentinelV1


V1版本的缺陷如下:

  • 业务侵入性很大,需要在你的controoler中写入 非业务代码.
  • 配置不灵活 若需要添加新的受保护资源 需要手动添加 init方法来添加流控规则

V2版本:基于V1版本,再添加一个依赖

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-annotation-aspectj</artifactId>
    <version>1.7.1</version>
</dependency>
  • 编写controller
// 配置一个切面
@Configuration
public class SentinelConfig {

    @Bean
    public SentinelResourceAspect sentinelResourceAspect() {
        return new SentinelResourceAspect();
    }

}

/**
 * 初始化流控规则
 */
@PostConstruct
public void init() {

    List<FlowRule> flowRules = new ArrayList<>();

    /**
     * 定义 helloSentinelV2 受保护的资源的规则
     */
    //创建流控规则对象
    FlowRule flowRule2 = new FlowRule();
    //设置流控规则 QPS
    flowRule2.setGrade(RuleConstant.FLOW_GRADE_QPS);
    //设置受保护的资源
    flowRule2.setResource("helloSentinelV2");
    //设置受保护的资源的阈值
    flowRule2.setCount(1);

    flowRules.add(flowRule2);
}

/**
 * 频繁请求接口 http://localhost:8080/helloSentinelV2
 * 优点: 需要配置aspectj的切面SentinelResourceAspect ,添加注解@SentinelResource
 *     解决了v1版本中 sentinel的业务侵入代码问题,通过blockHandler指定被流控后调用的方法.
 * 缺点: 若我们的controller中的方法逐步变多,那么受保护的方法也越来越多,会导致一个问题
 * blockHandler的方法也会越来越多   引起方法急剧膨胀 怎么解决
 *
 * 注意点:
 *   blockHandler 对应处理 BlockException 的函数名称,
 *   可选项。blockHandler 函数访问范围需要是 public,返回类型需要与原方法相匹配,
 *   参数类型需要和原方法相匹配并且最后加一个额外的参数,
 *   类型为 BlockException。blockHandler 函数默认需要和原方法在同一个类中
 * @return
 */
@RequestMapping("/helloSentinelV2")
@SentinelResource(value = "helloSentinelV2",blockHandler ="testHelloSentinelV2BlockMethod")
public String testHelloSentinelV2() {
    busiService.doBusi();
    return "OK";
}

public String testHelloSentinelV2BlockMethod(BlockException e) {
    log.info("testRt流控");
    return "testRt降级 流控...."+e;
}

测试效果:http://localhost:8080/helloSentinelV2

V3版本 基于V2缺点改进

/**
 * 初始化流控规则
 */
@PostConstruct
public void init() {

    List<FlowRule> flowRules = new ArrayList<>();

    /**
     * 定义 helloSentinelV3 受保护的资源的规则
     */
    //创建流控规则对象
    FlowRule flowRule3 = new FlowRule();
    //设置流控规则 QPS
    flowRule3.setGrade(RuleConstant.FLOW_GRADE_QPS);
    //设置受保护的资源
    flowRule3.setResource("helloSentinelV3");
    //设置受保护的资源的阈值
    flowRule3.setCount(1);



    flowRules.add(flowRule3);
}

/**
 * 我们看到了v2中的缺点,我们通过blockHandlerClass 来指定处理被流控的类
 * 通过testHelloSentinelV3BlockMethod 来指定blockHandlerClass 中的方法名称
 * ***这种方式 处理异常流控的方法必须要是static的
 * 频繁请求接口 http://localhost:8080/helloSentinelV3
 * @return
 */
@RequestMapping("/helloSentinelV3")
@SentinelResource(value = "helloSentinelV3",blockHandler = "testHelloSentinelV3BlockMethod",blockHandlerClass = BlockUtils.class)
public String testHelloSentinelV3() {
    busiService.doBusi();
    return "OK";
}

// 异常处理类
@Slf4j
public class BlockUtils {


    public static String testHelloSentinelV3BlockMethod(BlockException e){
        log.info("testHelloSentinelV3方法被流控了");
        return "testHelloSentinelV3方法被流控了";
    }
}

测试效果:http://localhost:8080/helloSentinelV3


缺点:不能动态的添加规则。如何解决问题?

3.2 如何在工程中快速整合Sentinel

<!--加入sentinel-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>


<!--加入actuator-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

添加Sentinel后,会暴露/actuator/sentinel 端点http://localhost:8080/actuator/sentinel

而Springboot默认是没有暴露该端点的,所以我们需要自己配置

server:
  port: 8080
management:
  endpoints:
    web:
      exposure:
        include: '*'

3.3 我们需要整合Sentinel-dashboard(哨兵流量卫兵)

下载地址:https://github.com/alibaba/Sentinel/releases (我这里版本是:1.6.3)

  • 第一步:执行 java -jar sentinel-dashboard-1.6.3.jar 启动(就是一个SpringBoot工程)
  • 第二步:访问我们的sentinel控制台(1.6版本加入登陆页面)http://localhost:8080/ ,默认账户密码:sentinel/sentinel
  • 第三步:我们的微服务 niuh04-ms-alibaba-sentinel-order 整合 sentinel,我们也搭建好了Sentinel控制台,为微服务添加sentinel的控制台地址
spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:9999

四、Sentinel监控性能指标详解

4.1 实时监控面板

在这个面板中我们监控我们接口的 通过的QPS拒绝的QPS,在没有设置流控规则,我们是看不到拒绝的QPS。

4.2 簇点链路

用来线上微服务的所监控的API


4.3 流控设置

簇点链路 选择具体的访问的API,然后点击“流控按钮


含义

  • 资源名:为我们接口的API /selectOrderInfoById/1
  • 针对来源:这里是默认的 default(标识不针对来源),还有一种情况就是假设微服务A需要调用这个资源,微服务B也需要调用这个资源,那么我们就可以单独的为微服务A和微服务B进行设置阀值。
  • 阀值类型:分为QPS和线程数,假设阀值为2
    • QPS类型:指的是每秒钟访问接口的次数 > 2 就进行限流
    • 线程数:为接受请求该资源,分配的线程数 > 2 就进行限流

流控模式

  1. 直接:这种很好理解,就是达到设置的阀值后直接被流控抛出异常

疯狂的请求这个路径


  1. 关联

业务场景:我们现在有两个API,第一个是保存订单,一个是查询订单,假设我们希望有限操作“保存订单


测试写两个读写测试接口

/**
 * 方法实现说明:模仿  流控模式【关联】  读接口
 * @author:hejianhui
 * @param orderNo
 * @return:
 * @exception:
 * @date:2019/11/24 22:06
 */
@RequestMapping("/findById/{orderNo}")
public Object findById(@PathVariable("orderNo") String orderNo) {
    log.info("orderNo:{}","执行查询操作"+System.currentTimeMillis());
    return orderInfoMapper.selectOrderInfoById(orderNo);
}


/**
 * 方法实现说明:模仿流控模式【关联】   写接口(优先)
 * @author:hejianhui
 * @return:
 * @exception:
 * @date:2019/11/24 22:07
 */
@RequestMapping("/saveOrder")
public String saveOrder() throws InterruptedException {
    //Thread.sleep(500);
    log.info("执行保存操作,模仿返回订单ID");
    return UUID.randomUUID().toString();
}

测试代码:写一个for循环一直调用我们的写接口,让写接口QPS达到阀值

public class TestSentinelRule {

    public static void main(String[] args) throws InterruptedException {
        RestTemplate restTemplate = new RestTemplate();
        for(int i=0;i<1000;i++) {
            restTemplate.postForObject("http://localhost:8080/saveOrder",null,String.class);
            Thread.sleep(10);
        }
    }
}

此时访问我们的读接口:此时被限流了。


  1. 链路

用法说明,本地实验没成功,用alibaba 未毕业版本0.9.0可以测试出效果,API级别的限制流量


代码:

@RequestMapping("/findAll")
public String findAll() throws InterruptedException {
    orderServiceImpl.common();
    return "findAll";
}

@RequestMapping("/findAllByCondtion")
public String findAllByCondtion() {
    orderServiceImpl.common();
    return "findAllByCondition";
}

@Service
public class OrderServiceImpl {

    @SentinelResource("common")
    public String common() {
        return "common";
    }
}

根据流控规则来说: 只会限制/findAll的请求,不会限制/findAllByCondtion规则

流控效果

  1. 快速失败(直接抛出异常)每秒的QPS 操作过1 就直接抛出异常

源码:com.alibaba.csp.sentinel.slots.block.flow.controller.DefaultController


  1. 预热(warmUp)
    源码:com.alibaba.csp.sentinel.slots.block.flow.controller.WarmUpController>

当流量突然增大的时候,我们常常会希望系统从空闲状态到繁忙状态的切换的时间长一些。即如果系统在此之前长期处于空闲的状态,我们希望处理请求的数量是缓步增加,经过预期的时间后,到达系统处理请求个数的最大值。Warm Up (冷启动,预热)模式就是为了实现这个目的。

冷加载因子:codeFacotr 默认是3

  • 默认 coldFactor 为3,即请求 QPS 从 threshold / 3 开始,经预热时长逐渐升至设定的 QPS 阀值。


上图设置:就是QPS从100/3=33开始算, 经过10秒钟,达到一百的QPS 才进行限制流量。

详情文档:https://github.com/alibaba/Sentinel/wiki/限流---冷启动

  1. 排队等待
    源码:com.alibaba.csp.sentinel.slots.block.flow.controller.RateLimiterController

这种方式适合用于请求以突刺状来到,这个时候我们不希望一下子把所有的请求都通过,这样可能会把系统压垮;同时我们也期待系统以稳定的速度,逐步处理这些请求,以起到“削峰填谷”的效果,而不是拒绝所有请求。

选择排队等待的阀值类型必须是****QPS


上图设置:单机阀值为10,表示每秒通过的请求个数是10,也就是每个请求平均间隔恒定为 1000 / 10 = 100 ms,每一个请求的最长等待时间(maxQueueingTimeMs)为 20 * 1000ms = 20s。,超过20s就丢弃请求。

详情文档:https://github.com/alibaba/Sentinel/wiki/流量控制-匀速排队模式

4.4 降级规则

rt(平均响应时间)


平均响应时间(DEGRADE_GRADE_RT):当 1s 内持续进入5个请求,对应时刻的平均响应时间(秒级)均超过阀值(count,以 ms 为单位),那么在接下来的时间窗口(DegradeRule 中的 timeWindow,以 s 为单位)之内,对这个方法的调用都会自动地熔断(抛出 DegradeException)。

注意:Sentinel 默认同级的 RT 上限是4900ms,超出此阀值都会算做4900ms,若需要变更此上限可以通过启动配置项:-Dcsp.sentinel.statistic.max.rt=xxx 来配置

异常比例(DEGRADE_GRADE_EXCEPTION_RATIO)

当资源的每秒请求量 >= 5,并且每秒异常总数占通过量的比值超过阀值(DegradeRule 中的 count)之后,资源进入降级状态,即在接下的时间窗口(DegradeRule 中的 timeWindow,以 s 为单位)之内,对这个方法的调用都会自动地返回。异常比例的阀值范围是 [0.0, 1.0],代表 0% ~ 100% 。

异常数(DEGRADE_GRADE_EXCEPTION_COUNT)

当资源近千分之的异常数目超过阀值之后会进行熔断。注意由于统计时间窗口是分钟级别的,若 timeWindow 小于 60s,则结束熔断状态后仍可能再进入熔断状态。


4.5 热点参数

业务场景:秒杀业务,比如商场做促销秒杀,针对苹果11(商品id=1)进行9.9秒杀活动,那么这个时候,我们去请求订单接口(商品id=1)的请求流量十分大,我们就可以通过热点参数规则来控制 商品id=1 的请求的并发量。而其他正常商品的请求不会受到限制。那么这种热点参数规则使用。


五、Sentinel-dashboard 控制台 和 我们的微服务通信原理

5.1 控制台如何获取到微服务的监控信息?

5.2 在控制台配置规则,如何把规则推送给微服务的?


我们通过观察到sentinel-dashboard的机器列表上观察注册服务微服务信息。我们的 控制台就可以通过这些微服务的注册信息跟我们的具体的微服务进行通信.


5.3 微服务整合sentinel时候的提供的一些接口API地址: http://localhost:8720/api

5.4 我们可以通过代码设置规则(我们这里用流控规则为例)

@RestController
public class AddFlowLimitController {

    @RequestMapping("/addFlowLimit")
    public String addFlowLimit() {
        List<FlowRule> flowRuleList = new ArrayList<>();

        FlowRule flowRule = new FlowRule("/testAddFlowLimitRule");

        //设置QPS阈值
        flowRule.setCount(1);

        //设置流控模型为QPS模型
        flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);

        flowRuleList.add(flowRule);

        FlowRuleManager.loadRules(flowRuleList);

        return "success";

    }

    @RequestMapping("/testAddFlowLimitRule")
    public String testAddFlowLimitRule() {
        return "testAddFlowLimitRule";
    }
}

添加效果截图: 执行:http://localhost:8080/addFlowLimit


Sentinel具体配置项:https://github.com/alibaba/Sentinel/wiki/启动配置项

5.5 对SpringMVC端点保护关闭(一般应用场景是做压测需要关闭)

spring:
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    sentinel:
      transport:
        dashboard: localhost:9999
      filter:
        enabled: true  #关闭Spring mvc的端点保护

那么我们的这种类型的接口 不会被sentinel保护


只有加了 @SentinelResource 的注解的资源才会被保护

六、Ribbon整合Sentinel

6.1 第一步:加配置

<!--加入ribbon-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</dependency>

<!--加入sentinel-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>


<!--加入actuator-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

6.2 第二步:加注解

在我们的RestTemplate组件上添加@SentinelRestTemplate注解。并且我们可以通过在@SentinelRestTemplate 同样的可以指定我们的 blockHandlerClass、fallbackClass、blockHandler、fallback 这四个属性

@Configuration
public class WebConfig {

    @Bean
    @LoadBalanced
    @SentinelRestTemplate(
            blockHandler = "handleException",blockHandlerClass = GlobalExceptionHandler.class,
            fallback = "fallback",fallbackClass = GlobalExceptionHandler.class

    )
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

*****************全局异常处理类*****************
@Slf4j
public class GlobalExceptionHandler {


    /**
     * 限流后处理方法
     * @param request
     * @param body
     * @param execution
     * @param ex
     * @return
     */
    public static SentinelClientHttpResponse handleException(HttpRequest request,
                                                             byte[] body, ClientHttpRequestExecution execution, BlockException ex)  {

        ProductInfo productInfo = new ProductInfo();
        productInfo.setProductName("被限制流量拉");
        productInfo.setProductNo("-1");
        ObjectMapper objectMapper = new ObjectMapper();

        try {
            return new SentinelClientHttpResponse(objectMapper.writeValueAsString(productInfo));
        } catch (JsonProcessingException e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 熔断后处理的方法
     * @param request
     * @param body
     * @param execution
     * @param ex
     * @return
     */
    public static SentinelClientHttpResponse fallback(HttpRequest request,
                                                      byte[] body, ClientHttpRequestExecution execution, BlockException ex) {
        ProductInfo productInfo = new ProductInfo();
        productInfo.setProductName("被降级拉");
        productInfo.setProductNo("-1");
        ObjectMapper objectMapper = new ObjectMapper();

        try {
            return new SentinelClientHttpResponse(objectMapper.writeValueAsString(productInfo));
        } catch (JsonProcessingException e) {
            e.printStackTrace();
            return null;
        }
    }
}

6.3 第三步:添加配置

什么时候关闭:一般在我们的自己测试业务功能是否正常的情况,关闭该配置

#是否开启@SentinelRestTemplate注解
resttemplate:
  sentinel:
    enabled: true

七、OpenFeign整合我们的Sentinel

7.1 第一步:加配置

在niuh05-ms-alibaba-feignwithsentinel-order上 pom.xml中添加配置

<!--加入sentinel-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>


<!--加入actuator-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
    <groupId>com.niuh</groupId>
    <artifactId>niuh03-ms-alibaba-feign-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

7.2 第二步:在Feign的声明式接口上添加fallback属性或者 fallbackFactory属性

  • 为我们添加fallback属性的api
@FeignClient(name = "product-center",fallback = ProductCenterFeignApiWithSentinelFallback.class)
public interface ProductCenterFeignApiWithSentinel {

    /**
     * 声明式接口,远程调用http://product-center/selectProductInfoById/{productNo}
     * @param productNo
     * @return
     */
    @RequestMapping("/selectProductInfoById/{productNo}")
    ProductInfo selectProductInfoById(@PathVariable("productNo") String productNo) throws InterruptedException;
}

我们feign的限流降级接口(通过fallback没有办法获取到异常的)

@Component
public class ProductCenterFeignApiWithSentinelFallback implements ProductCenterFeignApiWithSentinel {
    @Override
    public ProductInfo selectProductInfoById(String productNo) {
        ProductInfo productInfo = new ProductInfo();
        productInfo.setProductName("默认商品");
        return productInfo;
    }
}
  • 为我们添加fallbackFactory属性的api
package com.niuh.feignapi.sentinel;

import com.niuh.entity.ProductInfo;
import com.niuh.handler.ProductCenterFeignApiWithSentielFallbackFactoryasdasf;
import com.niuh.handler.ProductCenterFeignApiWithSentinelFallback;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * Created by hejianhui on 2019/11/22.
 */

@FeignClient(name = "product-center",fallbackFactory = ProductCenterFeignApiWithSentielFallbackFactoryasdasf.class)
public interface ProductCenterFeignApiWithSentinel {

    /**
     * 声明式接口,远程调用http://product-center/selectProductInfoById/{productNo}
     * @param productNo
     * @return
     */
    @RequestMapping("/selectProductInfoById/{productNo}")
    ProductInfo selectProductInfoById(@PathVariable("productNo") String productNo) throws InterruptedException;
}

通过FallbackFactory属性可以处理我们的异常

@Component
@Slf4j
public class ProductCenterFeignApiWithSentielFallbackFactoryasdasf implements FallbackFactory<ProductCenterFeignApiWithSentinel> {
    @Override
    public ProductCenterFeignApiWithSentinel create(Throwable throwable) {
        return new ProductCenterFeignApiWithSentinel(){

            @Override
            public ProductInfo selectProductInfoById(String productNo) {
                ProductInfo productInfo = new ProductInfo();
                if (throwable instanceof FlowException) {
                    log.error("流控了....{}",throwable.getMessage());
                    productInfo.setProductName("我是被流控的默认商品");
                }else {
                    log.error("降级了....{}",throwable.getMessage());
                    productInfo.setProductName("我是被降级的默认商品");
                }

                return productInfo;
            }
        };
    }
}

八、Sentinel 规则持久化

Sentinel-dashboard 配置的规则,在我们的微服务以及控制台重启的时候就清空了,因为它是基于内存的。


8.1 原生模式

Dashboard 的推送规则方式是通过 API 将规则推送至客户端并直接更新到内存。


优缺点:这种做法的好处是简单,无依赖;坏处是应用重启规则就会消失,仅用于简单测试,不能用于生产环境。

8.2 Pull拉模式


首先 Sentinel 控制台通过 API 将规则推送至客户端并更新到内存中,接着注册的写数据源会将新的规则保存到本地的文件中。使用 pull 模式的数据源时一般不需要对 Sentinel 控制台进行改造。

这种实现方法好处是简单,不引入新的依赖,坏处是无法保证监控数据的一致性

客户端Sentinel的改造(拉模式)

通过SPI扩展机制进行扩展,我们写一个拉模式的实现类 com.niuh.persistence.PullModeByFileDataSource ,然后在工厂目录下创建 META-INF/services/com.alibaba.csp.sentinel.init.InitFun文件。



文件的内容就是写我们的拉模式的实现类:


代码在niuh05-ms-alibaba-sentinelrulepersistencepull-order 工程的persistence包下。

8.3 Push推模式(以Nacos为例,生产推荐使用)

原理简述

  • 控制台推送规则:
    • 将规则推送至Nacos获取其他远程配置中心
    • Sentinel客户端连接Nacos,获取规则配置;并监听Nacos配置变化,如果发送变化,就更新本地缓存(从而让本地缓存总是和Nacos一致)
  • 控制台监听Nacos配置变化,如果发送变化就更新本地缓存(从而让控制台本地缓存和Nacos一致)

改造方案

微服务改造方案

  • 第一步:在niuh05-ms-alibaba-sentinelrulepersistencepush-order工程加入依赖
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
  • 第二步:加入yml的配置
spring:
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    sentinel:
      transport:
        dashboard: localhost:9999
        #namespace: bc7613d2-2e22-4292-a748-48b78170f14c  #指定namespace的id
      datasource:
        # 名称随意
        flow:
          nacos:
            server-addr: 47.111.191.111:8848
            dataId: ${spring.application.name}-flow-rules
            groupId: SENTINEL_GROUP
            rule-type: flow
        degrade:
          nacos:
            server-addr: localhost:8848
            dataId: ${spring.application.name}-degrade-rules
            groupId: SENTINEL_GROUP
            rule-type: degrade
        system:
          nacos:
            server-addr: localhost:8848
            dataId: ${spring.application.name}-system-rules
            groupId: SENTINEL_GROUP
            rule-type: system
        authority:
          nacos:
            server-addr: localhost:8848
            dataId: ${spring.application.name}-authority-rules
            groupId: SENTINEL_GROUP
            rule-type: authority
        param-flow:
          nacos:
            server-addr: localhost:8848
            dataId: ${spring.application.name}-param-flow-rules
            groupId: SENTINEL_GROUP
            rule-type: param-flow

Sentinel-dashboard改造方案

<!-- for Nacos rule publisher sample -->
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-datasource-nacos</artifactId>
   <!-- <scope>test</scope>--> // 需要把test注释掉
</dependency>

控制台改造主要是为规则实现:

  • DynamicRuleProvider :从Nacos上读取配置
  • DynamicRulePublisher :将规则推送到Nacis上

在sentinel-dashboard工程目录com.alibaba.csp.sentinel.dashboard.rule 下创建一 个Nacos的包,然后把我们的各个场景的配置规则类写到该包下.



我们以ParamFlowRuleController(热点参数流控类作为修改作为演示)

/**
 * @author Eric Zhao
 * @since 0.2.1
 */
@RestController
@RequestMapping(value = "/paramFlow")
public class ParamFlowRuleController {

    private final Logger logger = LoggerFactory.getLogger(ParamFlowRuleController.class);

    @Autowired
    private SentinelApiClient sentinelApiClient;
    @Autowired
    private AppManagement appManagement;
    @Autowired
    private RuleRepository<ParamFlowRuleEntity, Long> repository;

    @Autowired
    @Qualifier("niuhHotParamFlowRuleNacosPublisher")
    private DynamicRulePublisher<List<ParamFlowRuleEntity>> rulePublisher;

    @Autowired
    @Qualifier("niuhHotParamFlowRuleNacosProvider")
    private DynamicRuleProvider<List<ParamFlowRuleEntity>> ruleProvider;

    @Autowired
    private AuthService<HttpServletRequest> authService;

    private boolean checkIfSupported(String app, String ip, int port) {
        try {
            return Optional.ofNullable(appManagement.getDetailApp(app))
                .flatMap(e -> e.getMachine(ip, port))
                .flatMap(m -> VersionUtils.parseVersion(m.getVersion())
                    .map(v -> v.greaterOrEqual(version020)))
                .orElse(true);
            // If error occurred or cannot retrieve machine info, return true.
        } catch (Exception ex) {
            return true;
        }
    }

    @GetMapping("/rules")
    public Result<List<ParamFlowRuleEntity>> apiQueryAllRulesForMachine(HttpServletRequest request,
                                                                        @RequestParam String app,
                                                                        @RequestParam String ip,
                                                                        @RequestParam Integer port) {
        AuthUser authUser = authService.getAuthUser(request);
        authUser.authTarget(app, PrivilegeType.READ_RULE);
        if (StringUtil.isEmpty(app)) {
            return Result.ofFail(-1, "app cannot be null or empty");
        }
        if (StringUtil.isEmpty(ip)) {
            return Result.ofFail(-1, "ip cannot be null or empty");
        }
        if (port == null || port <= 0) {
            return Result.ofFail(-1, "Invalid parameter: port");
        }
        if (!checkIfSupported(app, ip, port)) {
            return unsupportedVersion();
        }
        try {
/*            return sentinelApiClient.fetchParamFlowRulesOfMachine(app, ip, port)
                .thenApply(repository::saveAll)
                .thenApply(Result::ofSuccess)
                .get();*/
            List<ParamFlowRuleEntity> rules = ruleProvider.getRules(app);
            rules = repository.saveAll(rules);
            return Result.ofSuccess(rules);
        } catch (ExecutionException ex) {
            logger.error("Error when querying parameter flow rules", ex.getCause());
            if (isNotSupported(ex.getCause())) {
                return unsupportedVersion();
            } else {
                return Result.ofThrowable(-1, ex.getCause());
            }
        } catch (Throwable throwable) {
            logger.error("Error when querying parameter flow rules", throwable);
            return Result.ofFail(-1, throwable.getMessage());
        }
    }

    private boolean isNotSupported(Throwable ex) {
        return ex instanceof CommandNotFoundException;
    }

    @PostMapping("/rule")
    public Result<ParamFlowRuleEntity> apiAddParamFlowRule(HttpServletRequest request,
                                                           @RequestBody ParamFlowRuleEntity entity) {
        AuthUser authUser = authService.getAuthUser(request);
        authUser.authTarget(entity.getApp(), PrivilegeType.WRITE_RULE);
        Result<ParamFlowRuleEntity> checkResult = checkEntityInternal(entity);
        if (checkResult != null) {
            return checkResult;
        }
        if (!checkIfSupported(entity.getApp(), entity.getIp(), entity.getPort())) {
            return unsupportedVersion();
        }
        entity.setId(null);
        entity.getRule().setResource(entity.getResource().trim());
        Date date = new Date();
        entity.setGmtCreate(date);
        entity.setGmtModified(date);
        try {
            entity = repository.save(entity);
            //publishRules(entity.getApp(), entity.getIp(), entity.getPort()).get();
            publishRules(entity.getApp());
            return Result.ofSuccess(entity);
        } catch (ExecutionException ex) {
            logger.error("Error when adding new parameter flow rules", ex.getCause());
            if (isNotSupported(ex.getCause())) {
                return unsupportedVersion();
            } else {
                return Result.ofThrowable(-1, ex.getCause());
            }
        } catch (Throwable throwable) {
            logger.error("Error when adding new parameter flow rules", throwable);
            return Result.ofFail(-1, throwable.getMessage());
        }
    }

    private <R> Result<R> checkEntityInternal(ParamFlowRuleEntity entity) {
        if (entity == null) {
            return Result.ofFail(-1, "bad rule body");
        }
        if (StringUtil.isBlank(entity.getApp())) {
            return Result.ofFail(-1, "app can't be null or empty");
        }
        if (StringUtil.isBlank(entity.getIp())) {
            return Result.ofFail(-1, "ip can't be null or empty");
        }
        if (entity.getPort() == null || entity.getPort() <= 0) {
            return Result.ofFail(-1, "port can't be null");
        }
        if (entity.getRule() == null) {
            return Result.ofFail(-1, "rule can't be null");
        }
        if (StringUtil.isBlank(entity.getResource())) {
            return Result.ofFail(-1, "resource name cannot be null or empty");
        }
        if (entity.getCount() < 0) {
            return Result.ofFail(-1, "count should be valid");
        }
        if (entity.getGrade() != RuleConstant.FLOW_GRADE_QPS) {
            return Result.ofFail(-1, "Unknown mode (blockGrade) for parameter flow control");
        }
        if (entity.getParamIdx() == null || entity.getParamIdx() < 0) {
            return Result.ofFail(-1, "paramIdx should be valid");
        }
        if (entity.getDurationInSec() <= 0) {
            return Result.ofFail(-1, "durationInSec should be valid");
        }
        if (entity.getControlBehavior() < 0) {
            return Result.ofFail(-1, "controlBehavior should be valid");
        }
        return null;
    }

    @PutMapping("/rule/{id}")
    public Result<ParamFlowRuleEntity> apiUpdateParamFlowRule(HttpServletRequest request,
                                                              @PathVariable("id") Long id,
                                                              @RequestBody ParamFlowRuleEntity entity) {
        AuthUser authUser = authService.getAuthUser(request);
        if (id == null || id <= 0) {
            return Result.ofFail(-1, "Invalid id");
        }
        ParamFlowRuleEntity oldEntity = repository.findById(id);
        if (oldEntity == null) {
            return Result.ofFail(-1, "id " + id + " does not exist");
        }
        authUser.authTarget(oldEntity.getApp(), PrivilegeType.WRITE_RULE);
        Result<ParamFlowRuleEntity> checkResult = checkEntityInternal(entity);
        if (checkResult != null) {
            return checkResult;
        }
        if (!checkIfSupported(entity.getApp(), entity.getIp(), entity.getPort())) {
            return unsupportedVersion();
        }
        entity.setId(id);
        Date date = new Date();
        entity.setGmtCreate(oldEntity.getGmtCreate());
        entity.setGmtModified(date);
        try {
            entity = repository.save(entity);
            //publishRules(entity.getApp(), entity.getIp(), entity.getPort()).get();
            publishRules(entity.getApp());
            return Result.ofSuccess(entity);
        } catch (ExecutionException ex) {
            logger.error("Error when updating parameter flow rules, id=" + id, ex.getCause());
            if (isNotSupported(ex.getCause())) {
                return unsupportedVersion();
            } else {
                return Result.ofThrowable(-1, ex.getCause());
            }
        } catch (Throwable throwable) {
            logger.error("Error when updating parameter flow rules, id=" + id, throwable);
            return Result.ofFail(-1, throwable.getMessage());
        }
    }

    @DeleteMapping("/rule/{id}")
    public Result<Long> apiDeleteRule(HttpServletRequest request, @PathVariable("id") Long id) {
        AuthUser authUser = authService.getAuthUser(request);
        if (id == null) {
            return Result.ofFail(-1, "id cannot be null");
        }
        ParamFlowRuleEntity oldEntity = repository.findById(id);
        if (oldEntity == null) {
            return Result.ofSuccess(null);
        }
        authUser.authTarget(oldEntity.getApp(), PrivilegeType.DELETE_RULE);
        try {
            repository.delete(id);
            /*publishRules(oldEntity.getApp(), oldEntity.getIp(), oldEntity.getPort()).get();*/
            publishRules(oldEntity.getApp());
            return Result.ofSuccess(id);
        } catch (ExecutionException ex) {
            logger.error("Error when deleting parameter flow rules", ex.getCause());
            if (isNotSupported(ex.getCause())) {
                return unsupportedVersion();
            } else {
                return Result.ofThrowable(-1, ex.getCause());
            }
        } catch (Throwable throwable) {
            logger.error("Error when deleting parameter flow rules", throwable);
            return Result.ofFail(-1, throwable.getMessage());
        }
    }

    private CompletableFuture<Void> publishRules(String app, String ip, Integer port) {
        List<ParamFlowRuleEntity> rules = repository.findAllByMachine(MachineInfo.of(app, ip, port));
        return sentinelApiClient.setParamFlowRuleOfMachine(app, ip, port, rules);
    }

    private void publishRules(String app) throws Exception {
        List<ParamFlowRuleEntity> rules = repository.findAllByApp(app);
        rulePublisher.publish(app, rules);
    }

    private <R> Result<R> unsupportedVersion() {
        return Result.ofFail(4041,
            "Sentinel client not supported for parameter flow control (unsupported version or dependency absent)");
    }

    private final SentinelVersion version020 = new SentinelVersion().setMinorVersion(2);
}

8.4 阿里云的 AHAS

第一步:访问 https://help.aliyun.com/document_detail/90323.html


第二步:免费开通

第三步:开通

第四步:接入应用

第五步:点击接入SDK

第六步:加入我们的应用

以niuh05-ms-alibaba-sentinelrulepersistence-ahas-order工程为例

  • 加入ahas的依赖
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>spring‐boot‐starter‐ahas‐sentinel‐client</artifactId> 4 <version>1.5.0</version>
</dependency>
  • 加入配置:yml的配置
ahas.namespace: default
project.name: order-center
ahas.license: b833de8ab5f34e4686457ecb2b60fa46
  • 测试接口
@SentinelResource("hot-param-flow-rule")
@RequestMapping("/testHotParamFlowRule")
public OrderInfo testHotParamFlowRule(@RequestParam("orderNo") String orderNo) {
    return orderInfoMapper.selectOrderInfoById(orderNo);
}

第一次访问接口:

AHas控制台出现我们的微服务

添加我们直接的流控规则

疯狂刷新我们的测试接口:

九、Sentinel 线上环境的优化

9.1 优化错误页面

  • 流控错误页面
  • 降级错误页面


发现这两种错误都是医院,显然这里我们需要优化 UrlBlockHandler 提供了一个接口,我们需要实现这个接口

/**
* @vlog: 高于生活,源于生活
* @desc: 类的描述:处理流控,降级规则
* @author: hejianhui
* @createDate: 2019/12/3 16:40
* @version: 1.0
*/
@Component
public class NiuhUrlBlockHandler implements UrlBlockHandler {

    public static final Logger log = LoggerFactory.getLogger(NiuhUrlBlockHandler.class);

    @Override
    public void blocked(HttpServletRequest request, HttpServletResponse response, BlockException ex) throws IOException {

        if(ex instanceof FlowException) {
            log.warn("触发了流控");
            warrperResponse(response,ErrorEnum.FLOW_RULE_ERR);
        }else if(ex instanceof ParamFlowException) {
            log.warn("触发了参数流控");
            warrperResponse(response,ErrorEnum.HOT_PARAM_FLOW_RULE_ERR);
        }else if(ex instanceof AuthorityException) {
            log.warn("触发了授权规则");
            warrperResponse(response,ErrorEnum.AUTH_RULE_ERR);
        }else if(ex instanceof SystemBlockException) {
            log.warn("触发了系统规则");
            warrperResponse(response,ErrorEnum.SYS_RULE_ERR);
        }else{
            log.warn("触发了降级规则");
            warrperResponse(response,ErrorEnum.DEGRADE_RULE_ERR);
        }
    }


    private void warrperResponse(HttpServletResponse httpServletResponse, ErrorEnum errorEnum) throws IOException {
        httpServletResponse.setStatus(500);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setHeader("Content-Type","application/json;charset=utf-8");
        httpServletResponse.setContentType("application/json;charset=utf-8");

        ObjectMapper objectMapper = new ObjectMapper();
        String errMsg =objectMapper.writeValueAsString(new ErrorResult(errorEnum));
        httpServletResponse.getWriter().write(errMsg);
    }

}

优化后:

  • 流控规则提示
  • 降级规则提示

9.2 针对来源编码实现


Sentinel 提供了一个 RequestOriginParser 接口,我们可以在这里实现编码从请求头中区分来源

/**
* @vlog: 高于生活,源于生活
* @desc: 类的描述:区分来源接口
* @author: hejianhui
* @createDate: 2019/12/4 13:13
* @version: 1.0
*/
/*@Component*/
@Slf4j
public class NiuhRequestOriginParse implements RequestOriginParser {

    @Override
    public String parseOrigin(HttpServletRequest request) {
        String origin = request.getHeader("origin");
        if(StringUtils.isEmpty(origin)) {
            log.warn("origin must not null");
            throw new IllegalArgumentException("request origin must not null");
        }
        return origin;
    }
}

配置设置区分来源为:yijiaoqian



9.3 解决RestFul风格的请求

例如:/selectOrderInfoById/2 、 /selectOrderInfoById/1 需要转为/selectOrderInfoById/{number}

/**
* @vlog: 高于生活,源于生活
* @desc: 类的描述:解决RestFule风格的请求
 *       eg:    /selectOrderInfoById/2     /selectOrderInfoById/1 需要转为/selectOrderInfoById/{number}
* @author: hejianhui
* @createDate: 2019/12/4 13:28
* @version: 1.0
*/
@Component
@Slf4j
public class NiuhUrlClean implements UrlCleaner {
    @Override
    public String clean(String originUrl) {
        log.info("originUrl:{}",originUrl);

        if(StringUtils.isEmpty(originUrl)) {
            log.error("originUrl not be null");
            throw new IllegalArgumentException("originUrl not be null");
        }
        return replaceRestfulUrl(originUrl);
    }

    /**
     * 方法实现说明:把/selectOrderInfoById/2 替换成/selectOrderInfoById/{number}
     * @author:hejianhui
     * @param sourceUrl 目标url
     * @return: 替换后的url
     * @exception:
     * @date:2019/12/4 13:46
     */
    private String replaceRestfulUrl(String sourceUrl) {
        List<String> origins = Arrays.asList(sourceUrl.split("/"));
        StringBuffer targetUrl = new StringBuffer("/");

        for(String str:origins) {
            if(NumberUtils.isNumber(str)) {
                targetUrl.append("/{number}");
            }else {
                targetUrl.append(str);
            }

        }
        return targetUrl.toString();
    }
}

PS:以上代码提交在 Githubhttps://github.com/Niuh-Study/niuh-cloud-alibaba.git

文章持续更新,可以公众号搜一搜「 一角钱技术 」第一时间阅读, 本文 GitHub org_hejianhui/JavaStudy 已经收录,欢迎 Star。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容