Redis实战

redis 和 memcached 的区别

1. redis支持更丰富的数据类型(支持更复杂的应用场景):Redis不仅仅支持简单的k/v类型的数据,同时还提供 list,set,zset,hash等数据结构的存储。memcache支持简单的数据类型,String。

2. Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache把数据全部存在内存之中。

3. 集群模式:memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 redis 目前 是原生支持 cluster 模式的.

4. Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的多路 IO复用模型

布隆过滤器基本使用

布隆过滤器有二个基本指令,bf.add 添加元素,bf.exists 查询元素是否存在,它的用法和 set 集合的 sadd 和 sismember 差不多。注意 bf.add 只能一次添加一个元素,如果想要一次添加多个,就需要用到 bf.madd 指令。同样如果需要一次查询多个元素是否存在,就需要用到 bf.mexists 指令。

统计和查找

Redis 提供了位图统计指令 bitcount 和位图查找指令 bitpos,bitcount 用来统计指定位置范围内 1 的个数,bitpos 用来查找指定范围内出现的第一个 0 或 1。

比如我们可以通过 bitcount 统计用户一共签到了多少天,通过 bitpos 指令查找用户从哪一天开始第一次签到。如果指定了范围参数[start, end],就可以统计在某个时间范围内用户签到了多少天,用户自某天以后的哪天开始签到。

Redis命令

字符串命令

Redis字符串可以存储3种类型的值:

  • 字节串(byte string)

  • 整数

  • 浮点数

整数和浮点数 可以有INCR和DECR等命令

字符串可以有 APPEND、SUBSTR、SETRANGE、SETBIT

列表

Redis的列表允许用户从序列的两端推入或者弹出元素,获取列表元素。

RPUSH、LPUSH、RPOP、LPOP、LINDEX、LRANGE、LTRIM,

也可以阻塞执行命令的客户端:BLPOP BRPOP RPOPLPUSH BRPOPLPUSH,可以当作消息队列使用。

集合

以无序方式来存储多个不相同的元素

SADD SREM SISMEMBER SCARD SMEMBERS SRANDMEMBER SPOP SMOVE

散列

多个键值对存储到一个Redis键里面

HMGET HMSET HDEL HLEN

有序集合

有序集合存储了成员与分值的映射,并且提供了分值处理命令,以及根据分值大小有序地获取(fetch)或扫描(scan)成员和分值的命令。

ZADD ZREM ZCARD ZINCRBY ZCOUNT ZRANK ZSCORE ZRANGE

Redis事务

基本事务需要用到MULTI和EXEC命令。Redis接收到MULTI命令,会把之后发送的所有命令都放到一个队列里面,直到收到EXEC命令位置,Redis会在不被打断的情况下,一个接一个执行存储在队列里面的所有命令。可以由pipeline犯法实现。

过期与删除

PERSIST:移除过期时间 TTL:查看剩余过期时间 EXPIRE:指定秒数后过期

数据安全与性能保障

Redis提供了两种不同的持久化方法来将数据存储到硬盘里面。一种叫快照(snapshotting),可以将存在于某一时间的数据都写入到硬盘里面。另一种叫做只追加文件(append-only file,AOF)它会在执行命令时,将执行的写命令复制到硬盘里面。

创建快照有以下几种方式:

  • 客户端发送BGSAVE创建一个快照(windows不支持),Redis会调用fork来创建一个子进程来将快照写入硬盘。(在Unix和类Unix系统上,进程创建子进程时父子进程共享相同内存,知道父进程或子进程写入内存后,对被写入内存共享才会结束)

  • 客户端发送SAVE命令创建快照,会阻塞进程。没有足够内存执行BGSAVE可以考虑

  • 用户设置了save配置选项,如save 60 10000。从最近一次创建快照之后当“60秒内有10000次写入”就会触发一次BGSAVE

  • Redis通过SHUTDOWN命令接收到关闭服务器命令,或者接受到标准TERM信号时,会执行SAVE

  • Redis连接到另一个Redis,并且想对方发送SYNC命令来开始一次复制操作,如果主服务器没有执行BGSAVE或者没有刚执行完BGSAVE,那么主服务器就会执行BGSAVE。

fork创建子进程

子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。这时你可以将父子进程想像成一个连体婴儿,共享身体。这是 Linux 操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的一瞬间,内存的增长几乎没有明显变化。

子进程做数据持久化,它不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化写到磁盘中。但是父进程不一样,它必须持续服务客户端请求,然后对内存数据结构进行不间断的修改。

这个时候就会使用操作系统的 COW (Copy On Write,写时复制)机制来进行数据段页面的分离。数据段是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复制一份分离出来,然后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的,还是进程产生时那一瞬间的数据。

随着父进程修改操作的持续进行,越来越多的共享页面被分离出来,内存就会持续增长。但是也不会超过原有数据内存的 2 倍大小。另外一个 Redis 实例里冷数据占的比例往往是比较高的,所以很少会出现所有的页面都会被分离,被分离的往往只有其中一部分页面。每个页面的大小只有 4K,一个 Redis 实例里面一般都会有成千上万的页面。

如果Redis进程占用了20GB的内存,在标准硬件上运行BGSAVE所创建的子进程将导致Redis停顿200-400毫秒。

AOF持久化

可以设置 appendonly yes 配置选项来打开 AOF持久化。

appendfsync有三个选项 always(每个写命令都同步) everysec(每秒)no(不显式写)

Redis会不断将写命令记录到AOF,随着不断运行体积会不断增长,重启之后需要重新执行AOF文件记录还原数据集,太大的AOF文件可能导致还原操作执行很长。可以使用BGREWRITEAOF命令移除AOF文件中的冗余命令重写AOF文件。

复制(replication,MS)

从服务器指定 slaveof host port的配置文件,发送SLAVEOF no one命令终止复制主服务器。发送SLAVEOF host port命令开始复制新服务器。

复制的过程:

1. 主:等待命令进入,从:连接(或重连)主服务器,发送SYNC命令

2. 主:执行BGSAVE,并使用缓冲区记录BGSAVE之后执行的写命令,从:根据配置决定是继续使用现有数据还是返回错误

3. 主:BGSAVE执行完毕,快照发送给从服务器,并继续用缓冲区记录写命令,从:丢弃旧数据载入快照文件

4. 主:快照文件发送完毕,发送缓冲区写命令,从:解释快照文件,正常执行

5. 主:缓冲区写命令发送完毕,每个写命令同步给服务器,从:执行所有写命令

主从链(master/slave chaining)从服务器可以有自己的从服务器。从服务器1加载快照文件时中断从服务器2的连接,从服务器2需要重新连接并重新同步。可以考虑多层主从链树

为了验证主服务器是否已经将写数据发送至从服务器,用户需要在向主服务器写入数据后再写一个唯一的虚构值(unique dummy value),通过检查虚构值是否存在与从服务器来判断写数据是否已经到达从服务器。

若要检查是否写入了硬盘中,可以检查INFO命令的输出结果中aof_pending_bio_fsync属性值是否为0,如果是表示服务器已经将所有已知的数据保存到硬盘。

发生故障后快照文件和AOF文件可以使用命令检查(redis-check-aof redis-check-dump),修复AOF就是丢弃出错命令以及之后的所有命令。快照无法修复。

更换主从服务器,主服务器挂了,从服务器SAVE生成快照,发送给新主加载完成后,从服务器执行SLAVEOF指定新主。(或者后面使用哨兵模式 Redis Sentinel)

事务

Redis的事务相关命令包括 WATCH MULTI/EXEC UNWATCH/DISCARD。

用户使用WATCH监视一个或多个键,接着使用MULTI开始一个新事务,多个命令入队,可以发送DISCARD取消WATCH并清空所有已入队命令。

也可以使用非事务型流水线(non-transactional pipeline)。执行pipleline传入true做参数表示使用事务,传入false表示无需事务。

分布式锁

使用SETNX命令实现锁的获取功能,这个命令只会在键不存在的情况下为键设置值。

降低占用内存

三种方式降低Redis占用:

  • 短结构(short structure)

  • 分片结构(shared structure)

  • 打包存储二进制位和字节

短结构

Redis为列表、集合、散列和有序集合提供了一组配置选项,这些选项可以让Redis以更节约空间的方式存储长度较短的结构。Redis可以选择使用一种名为压缩列表(ziplist)和紧凑存储方式来存储这些结构。压缩列表是列表、散列、和有序集合这3种不同类型的对象的一种非结构化(unstructured)表示,以序列化方式存储数据,这些序列化数据每次被读取的时候都要进行解码,每次被写入的时候都要进行局部的重新编码,并且可能需要对内存里面的数据进行移动。

Redis用双链表表示列表、散列表表示散列、散列表加上跳跃表(skiplist)表示有序集合。

键名尽量简短

分片结构

使用 namespace:id 这样的字符串键去存储短字符串或者计数器,能够有效降低存储这些数据所需的内存。

打包存储二进制位和字节

高效打包和更新Redis字符串的4个命令,分别是

GETRANGE、SETRANGE、GETBIT、SETBIT。

扩展Redis

Redis Sentinel可以配合Redis的复制功能使用,对下线的主服务器进行故障转移。当主服务器失效的时候,见识这个主服务器的所有Sentinel就会基于彼此共有的信息选出一个Sentinel,并从现有的从服务器当中选出一个新的主服务器。当被选中的从服务器转换为主服务器之后,那个被选中的Sentinel就会让剩余的其他服务器去复制这个新的主服务器(默认Sentinel会一个一个迁移从服务器,可以通过配置选项进行修改)

Redis的Lua脚本编程

使用EVAL和EVALSHA命令执行Lua脚本

Lua脚本和单个Redis命令以及“MULTI/EXEC”事务一样,都是原子操作

已经对结构进行了修改的Lua脚本无法中断

内存淘汰策略

不同于之前的版本,redis5.0为我们提供了八个不同的内存置换策略。很早之前提供了6种。

(1)volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。

(2)volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。

(3)volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。

(4)volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。

(5)allkeys-lru:从数据集中挑选最近最少使用的数据淘汰

(6)allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。

(7)allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰

(8) no-eviction(驱逐):禁止驱逐数据,这也是默认策略。意思是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用no-eviction策略可以保证数据不被丢失。

这八种大体上可以分为4中,lru、lfu、random、ttl。

LRU淘汰

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

在服务器配置中保存了 lru 计数器 server.lrulock,会定时(redis 定时程序 serverCorn())更新,server.lrulock 的值是根据 server.unixtime 计算出来进行排序的,然后选择最近使用时间最久的数据进行删除。另外,从 struct redisObject 中可以发现,每一个 redis 对象都会设置相应的 lru。每一次访问数据,会更新对应redisObject.lru

在Redis中,LRU算法是一个近似算法,默认情况下,Redis会随机挑选5个键,并从中选择一个最久未使用的key进行淘汰。在配置文件中,按maxmemory-samples选项进行配置,选项配置越大,消耗时间就越长,但结构也就越精准。

TTL淘汰

Redis 数据集数据结构中保存了键值对过期时间的表,即 redisDb.expires。与 LRU 数据淘汰机制类似,TTL 数据淘汰机制中会先从过期时间的表中随机挑选几个键值对,取出其中 ttl 最大的键值对淘汰。同样,TTL淘汰策略并不是面向所有过期时间的表中最快过期的键值对,而只是随机挑选的几个键值对。

随机淘汰:

在随机淘汰的场景下获取待删除的键值对,随机找hash桶再次hash指定位置的dictEntry即可。

Redis中的淘汰机制都是几近于算法实现的,主要从性能和可靠性上做平衡,所以并不是完全可靠,所以开发者们在充分了解Redis淘汰策略之后还应在平时多主动设置或更新key的expire时间,主动删除没有价值的数据,提升Redis整体性能和空间。

多路复用

在 I/O 多路复用模型中,最重要的函数调用就是 select,该方法的能够同时监控多个文件描述符的可读可写情况,当其中的某些文件描述符可读或者可写时,select 方法就会返回可读以及可写的文件描述符个数。

关于 select 的具体使用方法,在网络上资料很多,这里就不过多展开介绍了;

与此同时也有其它的 I/O 多路复用函数 epoll/kqueue/evport,它们相比 select 性能更优秀,同时也能支撑更多的服务。

Reactor 设计模式

Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符)

文件事件处理器使用 I/O 多路复用模块同时监听多个 FD,当 accept、read、write 和 close 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器。

虽然整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,实现了同时对多个 FD 读写的监控,提高了网络通信模型的性能,同时也可以保证整个 Redis 服务实现的简单。

I/O 多路复用模块

I/O 多路复用模块封装了底层的 select、epoll、avport 以及 kqueue 这些 I/O 多路复用函数,为上层提供了相同的接口。

I/O 多路复用模型是利用select、poll、epoll可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll是只轮询那些真正发出了事件的流),依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。

Redis集群不需要sentinel哨兵也能完成节点移除和故障转移的功能。需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展,据官方文档称可以线性扩展到上万个节点(官方推荐不超过1000个节点)。redis集群的性能和高可用性均优于之前版本的哨兵模式,且集群配置非常简单。

Redis无中心集群

Redis5之后提供了无中心的集群模式

Redis Cluseter 主要组件

key 分布模式,key空间分布被划分为16384个slot,所以一个集群,主节点的个数最大为16384(一般建议master最大节点数为1000)

Cluster bus,每个节点有一个额外的TCP端口,这个端口用来和其他节点交换信息。这个端口一般是在与客户端链接端口上面加10000,比如客户端端口为6379,那么cluster bus的端口为16379.

cluster 拓扑,Redis cluster 是一个网状的,每一个节点通过tcp与其他每个节点连接。假如n个节点的集群,每个节点有n-1个出的链接,n-1个进的链接。这些链接会一直存活。假如一个节点发送了一个ping,很就没收到pong,但还没到时间把这个节点设为 unreachable,就会通过重连刷新链接。

Nodes handshake,如果一个节点发送MEET信息(METT 类似ping,但是强迫接受者,把它作为集群一员)。一个节点发送MEET信息,只有管理员通过命令行,运行如下命令CLUSTER MEET ip port。如果这个节点已经被一个节点信任,那么也会被其他节点信任。比如A 知道B,B知道C,B会发送gossip信息给A关于C的信息。A就会认为C是集群一员,并与其建立连接。

失败检测,集群失效检测就是,当某个master或者slave不能被大多数nodes可达时,用于故障迁移并将合适的slave提升为master。当slave提升未能有效实施时,集群将处于error状态且停止接收Client端查询。

集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此交换各个节点状态信息,检测各个节点状态:在线状态、疑似下线状态PFAIL、已下线状态FAIL。当主节点A通过消息得知主节点B认为主节点D进入了疑似下线(PFAIL)状态时,主节点A会在自己的clusterState.nodes字典中找到主节点D所对应的clusterNode结构,并将主节点B的下线报告(failure report)添加到clusterNode结构的fail_reports链表中

如果集群里面,半数以上的主节点都将主节点D报告为疑似下线,那么主节点D将被标记为已下线(FAIL)状态,将主节点D标记为已下线的节点会向集群广播主节点D的FAIL消息,

所有收到FAIL消息的节点都会立即更新nodes里面主节点D状态标记为已下线。

将node标记为FAIL需要满足以下两个条件:

1.有半数以上的主节点将node标记为PFAIL状态。

2.当前节点也将node标记为PFAIL状态。

多个从节点选主

选新主的过程基于Raft协议选举方式来实现的

1)当从节点发现自己的主节点进行已下线状态时,从节点会广播一条

CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息,并且具有投票权的主节点向这个从节点投票

2)如果一个主节点具有投票权,并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条,CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点

3)每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持

4)如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于集群N/2+1张支持票时,这个从节点就成为新的主节点

5)如果在一个配置纪元没有从能够收集到足够的支持票数,那么集群进入一个新的配置纪元,并再次进行选主,直到选出新的主节点为止

故障转移

当从节点发现自己的主节点变为已下线(FAIL)状态时,便尝试进Failover,以期成为新的主。

以下是故障转移的执行步骤:

1)从下线主节点的所有从节点中选中一个从节点

2)被选中的从节点执行SLAVEOF NO NOE命令,成为新的主节点

3)新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己

4)新的主节点对集群进行广播PONG消息,告知其他节点已经成为新的主节点

5)新的主节点开始接收和处理槽相关的请求

分布式锁

1.互斥(必须):同一时刻,分布式部署的应用中,同一个方法/资源只能被一台机器上的一个线程占用。

2.锁失效保护(必须):出现客户端断电等异常情况,锁仍然能被其他客户端获取,防止死锁。

3.可重入(可选):同一个线程在没有释放锁之前,如果想再次操作,可以直接获得锁。

4.阻塞/非阻塞(可选):若没有获取到锁,返回获取失败

5.高可用、高性能(可选):获取释放锁最好是原子操作,获取释放锁的性能要好

version1

lock:SETNX key value

unlock:DEL key [key ...]

指令含义参考:http://doc.redisfans.com/string/setnx.html

这是第一版最简单的方案,保证在没有出现任何异常的时候多个客户端可以使用分布式锁。

但是问题来了,如下图中所示,client2在获取锁之后突然挂了,这时候锁k将无法释放,其他client就永远拿不到这把锁了。这就是需要解决的锁失效保护问题。

version2

我们可以给锁引入一个过期时间,这样即使client2挂了,锁过期之后其他client仍然能用。

EXPIRE key seconds

但此时同样会存在一些问题:

1)误删

解决方法是每个client塞给锁的value设定为唯一的随机字符串,在删除的时候先get一把,如果还是这个字符串的话才去删。

2)过期时间需大于业务执行时间,不然任务还没搞完就被别人抢了

这个时候需要开启另外一个线程专门去刷新锁的过期时间。

version3

我们需要尽量保证获取、释放锁的操作是原子性的,才能避免极端的异常情况。

原子性地加锁

SET key uniquevalue NX EX 20

原子性地解锁

我们可以使用原生的lua脚本


if redis.call("get",KEYS[1]) == ARGV[1] then

    return redis.call("del",KEYS[1])

else

    return 0


public void unlock() {

    // 使用lua脚本进行原子删除操作

    String checkAndDelScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +

                                "return redis.call('del', KEYS[1]) " +

                                "else " +

                                "return 0 " +

                                "end";

    jedis.eval(checkAndDelScript, 1, lockKey, lockValue);

}

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