分布式锁实践之一:基于 Redis 的实现

Redis分布式锁实践

什么是分布式锁?

我们日常工作中(以及面试中)经常说到的并发问题,一般都是指进程内的并发问题,JDK 的并发包也是用以解决 JVM 进程内多线程并发问题的工具。但是,进程之间、以及跨服务器进程之间的并发问题,要如何应对?这时,就需要借助分布式锁来协调多进程 / 服务之间的交互。

分布式锁听起来很高冷、很高大上,但它本质上也是锁,因此,它也具有锁的基本特征:

  1. 原子性
  2. 互斥性

除此之外,分布式的锁有什么不一样呢?简单来说就是:

  1. 独立性

    因为分布式锁需要协调其他进程 / 服务的交互,所以它本身应该是一个独立的、职责单一的进程 / 服务。

  2. 可用性

    因为分布式锁是协调多进程 / 服务交互的基础组件,所以它的可用性直接影响了一组进程 / 服务的可用性,同时也要避免:性能、饥饿、死锁这些潜在问题。

进程锁和分布式锁的区别:

图示 -- 进程级别的锁:

图示 -- 分布式锁:

分布式锁的业界最佳实践应该非大名鼎鼎的 ZooKeeper 莫属了。但杀鸡焉用牛刀?在直接使用 ZooKeeper 实现分布式锁方式之前,我们先通过 Redis 来演练一下分布式锁算法,毕竟 Redis 相对来说简单、轻量很多,我们可以通过这个实践来详细探讨分布式锁的特性。这之后再对比地去看 ZooKeeper 的实现方式,相信会更加容易地理解。

怎么实现分布式锁?

由于 Redis 是高性能的分布式 KV 存储器,它本身就具备了分布式特性,所以我们只需要专注于实现锁的基本特征就好了。

首先来看看如何设计锁记录的数据模型:

key value
lock name lock owner

举个例子,“注册表的分布式写锁”:

lock name lock owner
registry_write 10.10.10.110:25349

注意,为保证锁的互斥性,lock owner 标识必需保证全局唯一,不会如例子中显示的那样简单。

原子性

因为 Redis 提供的方法可以认为是并发安全的,所以只要保证加、解锁操作是原子操作就可以了。也就是说,只使用一个Redis方法来完成加、解锁操作的话,那就能够保证原子性。

  • 加锁操作: set(lockName, lockOwner, ...)

    set 是原子的,所以调用一次 set 也是原子的。

  • 解锁操作:eval(deleteScript, ...)

    这里你也许会疑惑,为什么不直接使用 del(key) 来实现解锁?因为解锁的时候,需要先判断你是不是加锁的进程,不是加锁者是无权解锁的。如果任何进程都能够解锁,那锁还有什么意义?

    因为“先判断是不是加锁者、然后再解锁”是两步的复合操作,而 Redis 并没有提供一个可以实现这个复合操作的直接方法,我们只能通过在 delete script 里面进行复合操作来绕过这个问题:因为执行一条脚本的 eval 方法是原子的,所以这个解锁操作的也是原子的。

互斥性

互斥性是说,一旦有一个进程加锁成功能,那么在该进程解锁之前,其他的进程都不能加锁。

在实现互斥性的同时,注意不能打破锁的原子性。

  • 加锁操作:set(lockName, lockOwner, "NX", ...)

    第 3 个参数 NX 的含义:只有当 lockName(key) 不存在时才会设置该键值。

  • 解锁操作:

    eval(
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "return redis.call('del', KEYS[1]) else return 0 end",
        List(lockName),
        List(lockOwner)
    )
    

    当解锁者等于锁的持有者时,才会删除该键值。

超时

解锁权唯一属于锁的持有者,如果持有者进程异常退出,就永远无法解锁了。针对这种情况,我们可以在加锁时设置一个过期时间,超过这个时间没有解锁,锁会自动失效,这样其他进程就能进行加锁了。

  • 加锁操作:set(lockName, lockOwner, "NX", "PX", expireTime)

    "PX" :过期时间单位:"EX" -- 秒,"PX" -- 毫秒

    expireTime : 过期时间

代码片段 1 :加锁、解锁

// 由Scala编写

case class RedisLock(client: JedisClient,
                     lockName: String,
                     locker: String) {
  private val LOCK_SUCCESS = "OK"
  private val SET_IF_NOT_EXISTS = "NX"
  private val EXPIRE_TIME_UNIT = "PX"
  private val RELEASE_SUCCESS = 1L

  def tryLock(expire: Duration): Boolean = {
    val res = client.con.set(
      lockName, // key
      locker, // value
      SET_IF_NOT_EXISTS, // nxxx
      EXPIRE_TIME_UNIT, // expire time unit
      expire.toMillis // expire time
    )
    val isLock = LOCK_SUCCESS.equals(res)
    println(s"${locker} : ${if (isLock) "lock ok" else "lock fail"}")
    isLock
  }

  def unlock: Boolean = {
    val cmd = 
      "if redis.call('get', KEYS[1]) == ARGV[1] then " +
      "return redis.call('del', KEYS[1]) else return 0 end"
    val res = client.con.eval(
      cmd,
      List(lockName), // keys
      List(locker) // args
    )
    val isUnlock = RELEASE_SUCCESS.equals(res)
    println(s"${locker} : ${if (isUnlock) "unlock ok" else "unlock fail"}")
    isUnlock
  }
}

测试加锁:

object TryLockDemo extends App {
  val client = JedisContext.client
  val lock1 = RedisLock(client, "LOCK", "LOCKER_1")

  // Try lock
  lock1.tryLock(1000.millis)
  Thread.sleep(2000.millis.toMillis)

  // Try lock after expired
  lock1.tryLock(1000.millis)

  // Unlock
  lock1.unlock
}

测试结果:

LOCKER_1 : lock ok   # 加锁成功,1秒后锁失效
LOCKER_1 : lock ok   # 2秒之后,锁已过期释放,所以成功加锁
LOCKER_1 : unlock ok # 解锁成功

阻塞加锁

到目前为止,我们实现了简单的加解锁功能:

  • 通过 tryLock() 方法尝试加锁,会立即返回加锁的结果
  • 锁拥有者通过 unlock() 方法解锁

但在实际的加锁场景中,如果加锁失败了(锁被占用或网络错误等异常情况),我们希望锁工具有同步等待(或者说重试)的能力。面对这个需求,一般会想到两种解决方案:

  1. 简单暴力轮询
  2. Pub / Sub 订阅通知模式

因为 Redis 本身有极好的读性能,所以暴力轮询不失为一种简单高效的实现方式,接下来就让我们来尝试下实现阻塞加锁方法。

先来推演一下算法过程:

  1. 设置阻塞加锁的超时时间 timeout
  2. 如果已超时,则返回失败 false
  3. 如果未超时,则通过 tryLock() 方法尝试加锁
  4. 如果加锁成功,返回成功 true
  5. 如果加锁失败,休眠一段时间 frequency 后,重复第 2 步

代码片段 2 :阻塞加锁

def lock(expire: Duration,
         timeout: Duration,
         frequency: Duration = 500.millis): Boolean = {
  var isTimeout = false
  TimeoutUtil.delay(timeout.toMillis).map(_ => isTimeout = true)
  while (!isTimeout) {
    if (tryLock(expire)) {
      return true
    }
    Thread.sleep(frequency.toMillis)
  }
  println(s"${locker} : timeout")
  return false;
}

代码片段 -- 超时工具类:

object TimeoutUtil {

  def delay(millis: Long): Future[Unit] = {
    val promise = Promise[Unit]()
    val timer = new Timer
    timer.schedule(new TimerTask {
      override def run(): Unit = {
        promise.success()
        timer.cancel()
      }
    }, millis)
    promise.future
  }
}

测试阻塞加锁:

object LockDemo extends App {
  val client = JedisContext.client
  val lock1 = RedisLock(client, "LOCK", "LOCKER_1")
  val lock2 = RedisLock(client, "LOCK", "LOCKER_2")

  // Lock
  lock1.lock(3000.millis, 1000.millis)
  lock2.lock(3000.millis, 1000.millis)
  lock2.lock(3000.millis, 3000.millis)

  // Unlock
  lock1.unlock
  lock2.unlock
}

测试结果:

LOCKER_1 : lock ok     # LOCKER_1 加锁成功,3 秒后锁失效
LOCKER_2 : lock fail   # LOCKER_2 尝试加锁失败
LOCKER_2 : lock fail   # LOCKER_2 重试,尝试加锁失败
LOCKER_2 : timeout     # LOCKER_2 重试超时,返回失败

LOCKER_2 : lock fail   # LOCKER_2 尝试加锁失败
LOCKER_2 : lock fail   # LOCKER_2 重试,尝试加锁失败
LOCKER_2 : lock fail
LOCKER_2 : lock fail
LOCKER_2 : lock ok     # 3 秒时间到,锁失效,LOCKER_2 加锁成功

LOCKER_1 : unlock fail # LOCKER_1 解锁失败,因为此时锁被 LOCKER_2 占有
LOCKER_2 : unlock ok   # LOCKER_2 解锁成功

更进一步

这个分布式锁的实现,有一个比较明显的缺陷,就是等待锁的进程无法实时的知道锁状态的变化,从而及时的做出响应。我们不妨思考一下,通过什么方式可以实时、高效的获得锁的状态?

作为分布式锁的业界标准,ZooKeeper 以及相关的工具库提供了更加直接、高效的支持,那么 ZooKeeper 是怎样的思路?具体又是如何实现的?欲知后事如何,且听下回分解:ZooKeeper 分布式锁实践。

0

介绍一个 MySQL 自动化运维利器 – Inception

引子

最近打算做一个 MySQL 的数据库运维平台。这里面有一个非常重要的功能就是 SQL 的审核,如果完全靠人工去实现就没必要做成一个平台了。正没头绪如何去实现的时候,google 了一下,看下有没有现成的开源方案。果不其然,github 上发现一个『去哪儿网』开源的一个数据库运维工具 Inception, 它是一个集审核、执行、备份及生成回滚语句于一身的 MySQL 自动化运维工具。

Inception 介绍

Inception 的架构图如下图所示,简单来说,Inception 就是一个 MySQL 的代理,能够帮助你审核 SQL,执行 SQL,备份 SQL 影响的记录。Inception 是一个 C/S 的软件架构。我们可以通过原生的 MySQL 客户端 去连接,也可以通过远程的接口去连接,目前执行只支持通过C/C++接口、Python接口来对Inception访问

inception-architecture.png

执行流程图如下:

image

安装 Inception

我安装的环境
OS: Ubuntu 16.04.2 LTS

安装依赖

  • 下载bison: 版本最好是2.6之前的(Ubuntu 16.04.2 LTS 版本下安装的是 bison-2.5.1),最新的可能会有问题,下载之后,需要自己编译源码来安装,具体安装方法,可以参数网上的一些说明。
  • cmake安装:apt-get install cmake
  • ncurses安装:apt-get install libncurses5-dev
  • 安装openssl:apt-get install libssl-dev
  • 安装g++:sudo apt-get install g++
  • 安装m4: apt-get install m4

编译安装 Inception

git clone https://github.com/mysql-inception/inception.git
sh inception_build.sh debug [linux]  (如果不指定就是linux平台,而如果要指定是Xcode,就后面指定Xcode)

可执行文件在 debug/sql/Debug/ 目录下面(不同平台有可能不相同)。

启动 Inception

创建一个配置文件 inc.cnf, 里面主要是配置 Inception 启动的端口,SQL 审核的策略,备份数据库的配置等等,更多可参考官方文档

[inception]
general_log=1
general_log_file=inception.log
port=6669   # Inception 的监听的端口
socket=/tmp/inc.socket
character-set-client-handshake=0
character-set-server=utf8
inception_remote_system_password=root  # 备份数据库密码
inception_remote_system_user=wzf1      # 备份数据库用户名
inception_remote_backup_port=3306      # 备份数据库端口
inception_remote_backup_host=127.0.0.1 # 备份数据库地址
inception_support_charset=utf8mb4
inception_enable_nullable=0
inception_check_primary_key=1
inception_check_column_comment=1
inception_check_table_comment=1
inception_osc_min_table_size=1
inception_osc_bin_dir=/data/temp
inception_osc_chunk_time=0.1
inception_enable_blob_type=1
inception_check_column_default_value=1

启动

./Inception --defaults-file=inc.cnf

访问
1. 通过原生的 MySQL 客户端的方式。主要注意的是,请不要将的 SQL 语句块,放到 MySQL 客户端中执行,因为这是一个自动化运维工具,如果使用交互式的命令行来使用的话没有意义,所有的 SQL 执行应该都通过接口的方式,这个方式仅仅可用来查看和设置上诉配置文件里的配置,如 inception get variables; 可查看所有的变量,更多请参考官方文档

mysql -uroot -h127.0.0.1 -P6669
  1. 通过接口的方式。下面是官方示例中的 Python 代码,需要注意的是如果使用 Python3 的 pymsql 去连接会有异常,目前的解决方案是需要修改 pymysql 的源码,具体 issue
#!/usr/bin/python
#-\*-coding: utf-8-\*-
import MySQLdb
sql='/*--user=username;--password=password;--host=127.0.0.1;--execute=1;--port=3306;*/\
inception_magic_start;\
use mysql;\
CREATE TABLE adaptive_office(id int);\
inception_magic_commit;'
try:
    conn=MySQLdb.connect(host='127.0.0.1',user='',passwd='',db='',port=9998)
    cur=conn.cursor()
    ret=cur.execute(sql)
    result=cur.fetchall()
    num_fields = len(cur.description) 
    field_names = [i[0] for i in cur.description]
    print field_names
    for row in result:
        print row[0], "|",row[1],"|",row[2],"|",row[3],"|",row[4],"|",
        row[5],"|",row[6],"|",row[7],"|",row[8],"|",row[9],"|",row[10]
    cur.close()
    conn.close()
except MySQLdb.Error,e:
     print "Mysql Error %d: %s" % (e.args[0], e.args[1])

SQL 审核 & 执行

通过 Inception 对语句进行审核时,必须要告诉 Inception 这些语句对应的数据库地址、数据库端口以及
Inception 连接数据库时使用的用户名、密码等信息,而不能简单的只是执行一条 sql 语句,所以必须要通过某种方式将这些信息传达给 Inception。

连接信息放在 /* ... */ 的注释中,真正的 SQL 语句则包括在 inception_magic_startinception_magic_commit:

/*--user=zhufeng;--password=xxxxxxxxxxx;--host=xxxxxxxxxx;
--enable-check;--port=3456;*/  
inception_magic_start;  
use mysql;  
CREATE TABLE adaptive_office(id int);  
inception_magic_commit;

连接的信息里可以配置更多的信息,比如关闭备份等等,具体请参考官方文档

审核

审核的规范见官方文档,有些规范是可配置的,可根据自己公司的规范在 Inception 的配置文件中配置。

执行

注意下,官方说是支持 DDL,DML 语句的,但是并不支持 SELECT 查询。

inception_accept.png

比如通过 Inception 执行一个建表语句:

...
inception_magic_start;  
use mysql;  
CREATE TABLE adaptive_office(id int);  
inception_magic_commit;
...

返回结果, 可见是每一条 SQL 就会返回一个可执行的结果,errlevel 非 0 时表示执行失败,下面所示中的第二条 SQL 语句 Audit completed(审核完成) 但是不符合建表的规范,更多关于返回结果的说明可见官方文档

'ID', 'stage', 'errlevel', 'stagestatus', 'errormessage', 'SQL', 'Affected_rows', 'sequence', 'backup_dbname', 'execute_time', 'sqlsha1'
1 | CHECKED | 0 | Audit completed | None | use inception_test | 0 | '0_0_0' | None | 0 | 
2 | CHECKED | 1 | Audit completed | Set engine to innodb for table 'adaptive_office'.
Set charset to one of 'utf8mb4' for table 'adaptive_office'.
Set comments for table 'adaptive_office'.
Column 'id' in table 'adaptive_office' have no comments.
Column 'id' in table 'adaptive_office' is not allowed to been nullable.
Set Default value for column 'id' in table 'adaptive_office'
Set a primary key for table 'adaptive_office'. | CREATE TABLE adaptive_office(id int) | 0 | '0_0_1' | 10_10_1_67_1028_inception_test | 0 | 

备份功能

前提条件

  • 线上服务器必须要打开 binlog,不然不会备份及生成回滚语句。
  • 参数 binlog_format 必须要设置为 mixed 或者 row 模式,通过语句:set global binlog_format=mixed/row 来设置,如果是 statement 模式,则不做备份及回滚语句的生成。
  • 被影响的行中必须存在主键,因为回滚语句的 WHERE 条件就是主键。比如,我插入一条数据并返回主键 id=1, 那么相应的它就会反向生成一个删除语句 (WHERE 的条件就是主键) DELETE FROM xx WHERE id = 1

Inception 在做 DML 操作时具有备份功能(默认开启,可通过在执行 SQL 中注释文件中指定 --disable-remote-backup),它会将所有当前语句修改的行备份下来,存储到一个指定的备份库中, 备份库通过配置 Inception 参数来指定。

关于备份数据库的命名方式,备份机器的库名组成是由线上机器的 IP 地址的点换成下划线,再加上端口号,再加上库名三部分,这三部分也是通过下划线连接起来的。例如:我执行 DML 操作的数据库地址是 192.168.1.1, 端口是 3306, 库名是 inceptiondb, 则在备份数据库中表名为:192_168_1_1_3306_inceptiondb

比如,我有一个 inception_test 库,其中有一张 userinfo 表,就两个字段:

userinfo.png

我通过 Inception 去执行一个 INSERT 一条记录:

/*--user=root;--password=xxx;--host=1.1.1.1;--execute=1;--port=3306;--sleep=0;--enable-remote-backup;*/\
inception_magic_start;\
use inception_test; \
insert into userinfo(`username`) values("test");\
inception_magic_commit;

返回的结果如下, 可以看到已经执行成功并且备份成功了:

2 | EXECUTED | 0 | Execute Successfully
Backup successfully | None | insert into userinfo(`username`) values("test") | 1 | '1533716166_25519001_1' | 1_1_1_1_3306_inception_test | 0.060 | 

查看下备份数据库中的 1_1_1_1_3306_inception_testuserinfo 表的结果, 根据 INSERT 的语句相应地生成了一条 DELETE 语句:

DELETE FROM `inception_test`.`userinfo` WHERE id=4;

那么,我需要如果正确地找到回滚的语句呢?

可以查看下备份库 1_1_1_1_3306_inception_testuserinfo 的表结构:

backup_userinfo.png

主要有两个字段:

  • rollback_statement text: 生成修改的回滚语句。
  • opid_time varchar(50): 这个列存储的是的被执行的 SQL 语句在执行时的一个序列号,这个序列号由三部分组成:timestamp(int 值,是语句被执行的时间点) + 线上服务器执行时所产生的 thread_id + 当前这条语句在所有被执行的语句块中的一个序号组成。可见上面的结果:1533716166_25519001_1, 这个序列号同时也会出现在执行返回的结果中,所有需要回滚就是根据这个序列号去备份表中查询回滚的 SQL 语句。

更多说明,请参考官方文档中的备份功能说明

最后

有了这么好用的工具,基于这个为基础,我们通过一个 WEB 应用做一个权限审批管理等功能,一个数据库运维平台就可以实现了,真的需要自己去写吗?我有发现了一个基于 Inception 实现的一个数据库运维平台 Yearning

感谢开源!

参考

0

小程序中 Redux 的使用

在我们的一款小程序中聊天部分主要是基于 Redux 来维护数据部分的。为什么使用了 Redux ?这也是符合了使用 Redux 的一些原则的。那么哪些情况使用 Redux 比较好呢?

用户的使用方式复杂
不同身份的用户有不同的使用方式(比如普通用户和管理员)
多个用户之间可以协作
与服务器大量交互,或者使用了 WebSocket
View 要从多个来源获取数据

我们的聊天功能基于 WebSocket 交互数据,使用方式较为复杂,多个地方都会影响聊天呈现的数据内容。并且与服务器交互量比较大,UI 上呈现的内容受到多个地方的影响。如下图:

 Redux 在小程序中的交互逻辑

图中展示了 Redux 的三大块业务实现部分与业务部分的交互逻辑,其中数据会反应在首页和聊天界面,而首页及聊天界面的一些操作又会通过 Action 反馈到 Redux 的数据对象上。另外 Websocket 和 Http 网络部分也会有很多数据反馈到 Redux 的数据对象上。

Redux 设计思想

简单总结为两句话:

(1)Web 应用是一个状态机,视图与状态是一一对应的。
(2)所有的状态,保存在一个对象里面。

Redux 的三大原则

  1. 单一数据源
  2. State 是只读的
  3. 使用纯函数来执行修改

其工作逻辑如下图所示:

Redux

Store

Store 就是保存数据的地方,你可以把它看成一个容器。整个应用只能有一个 Store。

import { createStore } from 'redux';
const store = createStore(fn);

通过 Store 可以获取到 State 对象,State 为时点的数据集合,即 Store 的一个快照。

import { createStore } from 'redux';
const store = createStore(fn);

const state = store.getState();

Action

State 的变化,会导致 View 的变化。但是,用户接触不到 State,只能接触到 View。所以,State 的变化必须是 View 导致的。Action 就是 View 发出的通知,表示 State 应该要发生变化了。

const action = {
  type: 'ADD_TODO',
  payload: 'Learn Redux'
};

Dispatch

store.dispatch()是 View 发出 Action 的唯一方法。

import { createStore } from 'redux';
const store = createStore(fn);

store.dispatch({
  type: 'ADD_TODO',
  payload: 'Learn Redux'
});

Subscribe

Store 允许使用store.subscribe方法设置监听函数,一旦 State 发生变化,就自动执行这个函数。

import { createStore } from 'redux';
const store = createStore(reducer);

store.subscribe(listener);

小程序

在 Redux 的使用中我们主要会去实现两个部分,一是 Action 部分,去构造定义要发送的 Action 的数据格式等,另一部分是 Reducer 部分,即 Dispatch 分发的 Action 的具体相应处理部分。Reducer 即接收原 State 和 Action,根据当前 Action 重新创建一份新的 State,然后返回这个 State。

消息的处理逻辑一开始并不是很好,发送消息、接收消息、发送中、接收中等各种消息的状态,都会单独发送不同的 Action 这也导致 Reducer 的维护变得非常困难,而且导致很多不一致的地方。

Actions_to_Action

后来改为一个 Action 做统一处理,处理起来简单了很多,将原有的多种 Action,多种接收 Action 并处理的逻辑统一成一种,当然如果是维护的不同数据那么还是需要分开来处理的。

修改后 Action 的实现

修改后消息 Action 接口如下:

onMessage(message, doctorId)

如两处修改消息 Action 的使用:

  • 接收消息,WebSocket 接收到新的消息时,因为接收到的消息分为发送出去的,和接收到的两种:
onMessage({
    ...message,
    sending: { 
        status: 0 
    }},
    message.from === config.patientId ? message.to : message.from
);
  • 发送消息,本地发送消息时,将其更新到界面上,并调用WebSocket发送接口将其发送出去。
const sendText = async (doctorId, text) => {
  const message = {
    typ: MSG.TEXT,
    content: toRealText(text),
    to: doctorId,
    mine: true,
    sending: {
      status: 1
    },
    guid: guid(),
    from: config.patientId,
    created: Date.now()
  }
  onMessage(message, doctorId)
}

修改后 Reducer 的实现

在 Reducer 统一提供一处消息 Action 的处理方式,避免之前的多处处理导致的数据不一致的情况:

[CHAT_MESSAGE] (_state, _action) {

    // 拷贝一份 state
    let state = {..._state};

    // 提取消息参数
    _handle(_state, _action);

    // 处理消息对象
    // 修改 state
    // ...

    return state;
}

修改后 UI 订阅状态

最后我们需要将 State 中维护的数据对象显示到 UI 上,在 Javascript 中我们可以使用 @connect 在页面上加上修饰,通过 @connect 实现内容的 subscribe 过程,将 messages 方法注入到页面中的 data 中。

@connect({
    messages(state) {
      const chat = state.chat[this.doctorId];
      if (chat) {
        return chat.messages;
      }
      return [];
    }
})

对于首页也是同样的道理,在首页不需要消息列表,但是需要小时列表的摘要信息以显示有多少种消息列表。也即当前维护着的对话数量。

@connect({
    sessions(state) {
        let sessions = []
        for (let id in state.chat) {
            let session = state.chat[id]
            sessions.push(session.session)
        }
        sessions.sort((a, b) => {
        return b.updated - a.updated
        })
        return sessions
    }
});

总结

小程序里使用到的内容较为简单,Redux 原本也就是简化 Web 中状态和界面简单对应关系,使用时只需要关注其三大原则即可。并尽可能地统一相同的修改操作,保持数据的统一性。

参考

http://www.redux.org.cn/

http://www.ruanyifeng.com/blog/2016/09/redux_tutorial_part_one_basic_usages.html

0

数据可视化过程不完全指南

数据集犹如世界历史状态的快照,能帮助我们捕捉不断变化的事物,而数据可视化则是将复杂数据以简单的形式展示给用户的良好手段(或媒介)。结合个人书中所学与实际工作所学,对数据可视化过程做了一些总结形成本文供各位看客"消遣"。

个人以为数据可视化服务商业分析的经典过程可浓缩为:从业务与数据出发,经过数据分析与可视化形成报告,再跟踪业务调整回到业务,是个经典闭环。

image

本文主题为数据可视化,将重点讲解与数据可视化相关的环节,也即上图中蓝色的环节。

一、理解 DATA

进行 DATA 探索前,我们需先结合业务去理解 DATA,这里推荐运用 5W1H 法,也即在拿到数据后问自身以下几个问题:

  • Who:是谁搜集了此数据?在企业内可能更关注是来自哪个业务系统。
  • How:是如何采集的此数据?尽可能去了解详细的采集规则,采集规则是影响后续分析的重要因素之一。如:数据来自埋点,来自后端还是前端差异很大,来自后端则多是实时的,来自前端则需更近一步了解数据在什么网络状态会上传、无网络状态下又是如何处理的。
  • What:是关于什么业务什么事?数据所描述的业务主题。
  • Why:为什么搜集此数据?我们想从数据中了解什么,其实也就是我们此次分析的目标。
  • When:是何时段内的业务数据?
  • Where:是何地域范围内的业务数据?

通过回答以上几个问题,我们能快速了解:数据来源是什么?它的可信度有多少?它在描述何时发生的怎样的业务(问题)?我们为什么要搜集此数据?等等。从而快速了解数据与业务开展近一步的探索与分析。

二、探索 DATA

之前的文章中,我们曾经分享过如何快速地探索 DATA (「如何成为一名数据分析师:数据的初步认知」),其中有谈到如何通过诸如平均数/中位数/众数等描述统计、通过相关系数统计快速探索 DATA 的方法。本文主要讲解可视化,所以将从可视化的角度去介绍如何通过可视化方法进行数据探索。

在探索、研究阶段,更重要的是要从不同的角度去观察数据,并逐步深入到对业务更重要的事情上。在这个阶段,我们不必去过多地追求图表美化,而应该尽可能快速地尝试更多个角度。下面我们根据数据/主题类型的差异分开阐述:

1. 分类数据的探索

在业务分析中,我们常常将人群、地点和其他事物进行分类,分类能为我们带来结构化,能让我们快速掌握信息。

在分类数据可视化中,我们最多使用的是条形图;但当试图观察分类中的比例时,我们可能也会选择饼图、瀑布图;当不仅关心一级分类还关心子分类时候,我们可能会选择树形图。通过对分类数据的可视化,我们能快速地获取最大、最小值,同时也能方便地了解到数据集的范围,因为它在一定程度上还反映了数据分布特征。下图展示了可视化分类数据的一些选择:

a. 条形图,用长度作为视觉暗示,利于直接比较。

image

b. 使用饼图、柱形堆叠图、瀑布图等,能在分类数据中对比占比情况。

image

c. 使用树形图,能在展示一级分类的子类统计,可实现维度的又一层下钻。

image

2. 时序数据的探索

业务分析中,我们常常关心事物随着时间的变化趋势,以及数据随时间变化的规律(时间周期下的规律)。所以,对时序数据的探索,主要有两种模式:其一为随着时间线索向右延伸的时序图,诸如:折线图、堆积面积图等;其二为根据时间周期,统计汇总的柱形图、日历图、径向图等。

a. 用于观察事物随时间线索变化的探索。

image

b. 用于发现事物随时间周期变化规律的探索。

image
image

3. 空间数据的探索

空间数据探索主要是期望展现或者发现业务事件在地域分布上的规律,即区域模式。全球数据通常按照国家分类,而国内数据则按照省份去分类,对于省份数据则按照市、区分类,以此类推,逐步向细分层次下钻。空间数据探索最常用为等值热力图,如下:

image

4. 多元变量的探索

数据探索过程中,有时候我们需要对比多个个体多个变量,从而寻找数据个体间的差异或者数据变量间的关系。在这种情况下,我们推荐使用散点图、气泡图,或者将多个简单图表组合生成“图矩阵”,通过对比“图矩阵”来进行多元变量的探索。其中,散点图和气泡图适合变量相对较少的场景,对于变量5个及以上的场景我们更多地是推荐“图矩阵”。

a. 变量相对较少(5个以下)的场景我们采用散点图与气泡图。

image

b. 变量多(5个及以上)的场景我们采用多个简单图表组成的“图矩阵”,下图为最简单的“图矩阵”多元热力图:

image

5. 数据分布的探索

探索数据的分布,能帮助我们了解数据的整体的区间分布、峰值以及谷值以及数据是否稳定等等。

之前在分类数据探索阶段曾提到分类清晰的条形图在一定程度上向我们反映了数据的分布信息。但,之前我们是对类别做的条形图,更多时候我们是需查看数据“坐落区间”,这里我们推荐直方图以及直方图的变型密度曲线图(密度曲线图,上学时代学的正态分布就常用密度曲线图绘制)。此外,对数据分布探索有一个更为科学的图表类型,那就是:箱线图。
image

三、图表清晰

1. 合理"搭配"可视化的组件

所谓可视化,其实就是根据数据,用标尺、坐标系、各种视觉暗示以及背景信息描述进行组合来表现数据。下图为可视化组件的“框架图”:

image

a. 视觉暗示

可视化最基本的形式就是简单地将数据映射成图形,大脑可以在数字与图形间来回切换从而寻找模式。所以我们必须选择合适的视觉暗示来保证数据的本质没有在大脑地来回切换中丢失,并且尽可能让大脑能轻松获得信息。

image

从上到下,对人脑而言视觉暗示清晰程度逐渐降低。

位置

使用位置作视觉暗示时,大脑是在比较给定空间或者坐标系中数值的位置。它的优势在于占用空间会少于其他视觉暗示,但劣势也很明显,我们很难去辨别每一个点代表什么。所以,应用位置作为视觉暗示主要用于发现趋势规律或者群集分布规律,散点图是位置作为视觉暗示的典型运用。

长度

使用长度作为视觉暗示,大脑的理解模式是条形越长,绝对值越大。优点非常明显人眼对于长度的“感受”往往是最准确的。条形图是长度作为视觉暗示的最常见图表。

角度

使用角度作为视觉暗示,大脑的理解模式为两向量如何相交,相交角度是否大于90度或180度。角度作为视觉暗示的最常见图表式饼图。

方向

使用方向作为视觉暗示,大脑的理解模式为坐标系中一个向量的方向。在折线图中显示为斜率,在迁徙图中显示为箭头所指方向。

形状

使用形状作为视觉暗示,对大脑而言往往代表着不同的对象或者类别。可用于在散点图中区分不同群集。

面积/体积

使用面积/体积作为视觉暗示,面积大则绝对值大。需要注意的一点是,用面积显示2倍关系时,应该是面积乘倍而不是边长乘倍。

色相与饱和度

不同的颜色通常用来表示分类数据,每个颜色代表一个分组;不同的色相通畅用来表示连续数据,常见模式是颜色越深代表数值越大。

b. 坐标系

  • 直角坐标系:绝大多数的图表都在直角坐标系中完成,它是最常用的坐标系。在直角坐标系中,关注的两个点之间的距离,距离是欧式距离。
  • 极坐标系:极坐标系是显示角度的坐标系,如果用过饼图那么就已经接触过极坐标系了。
  • 地理坐标系:简单点理解,它由经纬度组成,将世界各地的位置显示在图表中,因与现实世界直接相关而倍受喜爱。

c. 标尺

标尺的重要性在于与坐标系一起决定了图形的投影方式。

  • 线性标尺:间距处处相等,无论处于什么位置,是大众最熟悉、最容易接受的标尺,不容易产生误解;
  • 分类标尺:分类数据往往采用分类标尺,如:年龄段、性别、学历等等,值得注意的一点是,对于有序的分类,我们应尽量对分类标尺做排序以适应读者的阅读模式;
  • 百分比标尺:其实仍旧是线性标尺,只是刻度值为百分比;
  • 对数标尺:指按照对数化将坐标轴压缩,适合数值跨度非常大的场景。但需考虑读者是否能够适应对数标尺,毕竟它并不常见。

d. 背景信息

背景信息,所指即我们在理解 DATA 通过 “5W1H” 法回答的问题。包括数据背景与业务背景。

基本的原则是,如果信息在图形元素中没有得到巧妙地暗示,我们久需要通过标注坐标轴、注明度量单位,添加额外说明等方法来告诉读者图表中每一个数据及其视觉暗示代表什么。

2. 美化,让可视化更为清晰

在研究阶段,我们重点尝试从各种不同的角度切入去观察数据,没有过多地考虑表达是否准确,图形是否美观。
但,当我们进展到准备将分析报告呈现给业务方或领导时,必须对可视化图表进行优化使其是清晰易读的。否则,我们很可能要挨批了。

image

上图为,数据可视化与现实世界的连接关系。清晰易读的可视化一定是在尽可能地减少读者从可视化图表理解转换为现实世界的难度。而增强数据比较、合理注解引导、减少读者理解步骤是达成这一目的的良好手段,下面为大家详细展开介绍:

a. 增强数据比较,降低大脑进行信息比较的难度

当我们在阅读可视化图表时,我们的大脑会自然地进行比较从而获取信息。增强数据比较,可有效降低信息比较难度,使大脑更容易抓住关键信息,减少模凌两可,使大脑获取信息更具确定性。

建立视觉层次,用醒目的颜色突出数据,淡化其他元素

有层次感的图表更易读,用户能更快地抓住图表中的重点信息。相反,扁平图则缺少流动感,读者相对较难理解。建立视觉层次,我们可以用醒目的颜色突出显示数据,并淡化其他元素使其作为背景,淡化元素可采用淡色系或虚线。

散点图的目标是为寻找规律与模式,拟合数据线是下图的关键。弱化数据点、强化拟合趋势线使其形成鲜明的2个层次。

image

高亮显示重点内容

高亮显示可以帮助读者在茫茫数据中一下找到重点。它既可以加深人们对已看到数据的印象,也可以让人们关注到那些应该注意的东西。需要注意的是,使用“高亮”突出显示时,我们应尽可能使用当前图表中尚未使用的视觉暗示。

下面为常见的电商转化漏斗,其中下单步骤是最应当关注的环节,使用红色高亮能会使读者的目光快速落在这一关键步骤中。

image

其他技巧

除了以上介绍两大增强比较技巧,我们可以通过以下一些小技巧来增强数据比较:

  • 提升色阶跨度,倘若图表中所用颜色色阶跨度太小,我们将难以区分差异,合理提升色阶跨度能有效增强比较;
  • 合理增大标尺跨度,有时候我们只需要对标尺做合理地放大,数据差异将清晰好几倍;
  • 添加参考线(建议采用虚线),参考线作为对比基准,可有效增强数值与基准的比较。

b. 合理注解与引导,使读者快速理解图表信息并抓住信息重点

仅通过图形元素,我们很难向读者展示充分的信息,合理增加注解能有效帮助读者理解图表;增加适当的箭头等符号引导能帮助读者快速抓住关键信息。

合理注解:背景信息、分析结论以及统计学概念

如果报表的读者对数据、业务背景并不十分熟悉,我们应考虑在标题或其他报告文字中直接说明背景。

如果是结论性图表,我们可在主标题中直接说明结论。如果结论得出的过程较复杂,我们还可以在副标题中辅助说明是如何推导得到的结论。

如果图表中,有大部分读者都不熟悉的统计学概念,我们应适当地进行注解,以帮助读者了解相关概念。

下图,主标题数据背景注解让读者快速了解业务背景,副标题说明结论能有效引导读者朝着什么方向去阅读图表

image

合理增加引导:增加适当的箭头指向

分析阶段,我们是报表的制作者;汇报阶段,我们是报告的讲解者。我们可以将自身作为报告的导游,引导读者按照我们的期望去阅读图表。而增加箭头等符号的引导是最直接有效的方式。

c. 通过引入计算、视觉暗示直接符合读者“背景暗示”等方法可有效降低读者理解步骤

创造性地从不同角度进行计算

有时,我们只需在图表上先做一个图表计算就可以让图表离结论更近一个层次,从而减少读者从可视化图表到现实世界的理解步骤。常见的可用计算包括:平均值计算、环比增长率、基准点上下、累加统计等。

示例1:将员工销售业绩与团队均值做差值,快速辨别员工的销售表现

image

示例2:将2个采购商的采购成本按照一年累计汇总后可使采购成本差异更显著

image

选择符合读者“背景期望”的视觉暗示

人在世界上生存久了都会形成一定的潜意识,有一些潜意识是“人群通用的”,在可视化过程中,我们应该合理运用。比如:在失业、就业统计中,失业用负数表示,就业用正数表示,就是一种符合大多数人“背景期望”的一种场景。

示例1: 之前在一本书中看到的一个关于伊拉克战争可视化。此图的主题在于批判战争的残酷造成了巨大的伤亡,所以作者采用了与血液相同的红色作为主色调,倒挂的柱形也能给人以压抑感,同样符合“背景期望”。

image

示例2: 之前一位同事分享的一个关于美国一些互联网平台网红收入的可视化。在色彩上它直接采用对应互联网平台自身logo的色系。符合人的“背景期望”阅读过程将非常轻松。

image

四、适应读者

别忘了,我们的可视化是为读者进行的,我们应考虑目标读者的特点制作他们易于、乐于理解的可视化。尤其要避免的一个陷阱是:过分追求新颖图表,反而使得图表难以理解,结果违背了可视化的初衷。

为读者而可视化,要求我们试图去了解读者,了解他们对可视化的偏好,尤其是能够接受新颖的图表类型,以及他们对业务的理解程度等等。

此外,还有一个非常关键且通用的建议:让我们的报告以讲故事的方式展开,我们自身则作为这个报告的导游,合理有效地引导读者看完你创造的“分析故事”。


好,以上即为个人对数据可视化服务商业分析的过程所有总结。

0