数据库的设计要点
索引
提起数据库的设计要点,我们首先要说的就是数据库索引的使用,在线上的服务中,任何数据库的查询都要走索引,这个是底线,不能因为数据量暂时较小就不使用索引,久而久之可能数据量增大就导致了性能问题,一般每个开发者都有建立索引和使用索引的意识,然而,问题出现在开发者使用索引的方法上。我们要保证建立的索引的有效性,一定要确保线上的查询最后走到了索引,曾经就出现过这样的一个低级错误,某个场景需要根据 A、B、C 三个字段联合查询,开发者分别在 A、B 和 C 上建立了3个索引,看似也符合规范,但是实际上只用了 A 这个索引,B 和 C 的都没有用上,后来由于产生了性能问题,代码走查的时候才发现。
我们建议每个开发者对使用的 SQL 都要查看执行计划,另外,SQL 和索引要经过 DBA 的审阅才能上线。
另外,对于一般的数据库,>=、BETWEEN、IN、LIKE 等都可以走索引,而 NOT IN 不能走索引,如果匹配的字符以 % 开头,是不能走索引的,这些必须记住了。
范围查询
任何针对数据库的范围查询,都要有最大结果集条数的限制,然后进行分页处理,不能因为暂时数据量小而采用开发式的 SQL 语句,如果这样的话,在数据上量以后,会导致结果集太大,而让应用 OOM。
下面是主流数据库限制结果集大小的方法。
DB2
FETCH FIRST 100 ROWS ONLYSELECT id FROM( SELECT ROW_NUMBER() OVER() AS num,id FROM TABLE ) A WHERE A.num>=1 AND A.num<= 100
MySQL
limit 1, 100
Oracle
rownum
Schema 变更
对于数据库的 Schema 变更,我们推荐只能增加字段,而不要修改字段,也不要删除字段,修改和删除字段的风险太高了,尤其是在应用比较复杂,数据库和应用的设计都是做加法加上来的,对于使用数据库的应用了解不清楚,不要轻易更改原有的数据结构,修改字段就有可能导致代码和数据库不兼容的情况。
即使是只允许添加字段,我们也做如下的规定。
新代码要兼容老数据,老代码要兼容新数据。
要尽量让新老代码和新老数据库 Schema 完全兼容,这在数据库升级前、中、后都不会产生问题。
字段枚举值的增加,或者数据库字段的含义、格式、限制的改变,必须考虑准生产和线上导致的不一致的行为或者上线过程中新老版本的不一致的行为。曾经就出现过,版本更新的时候增加了枚举值,由于 Boss 后台先上线,产生了新的枚举值,结果交易程序没有更新,不认识新的枚举值就出现了处理异常,因此枚举值要慎用。
事务
经常会出现在数据库事务中调用远程服务,由于远程服务超时而拉长事务,导致数据库瘫痪的情况,因此,在事务处理过程中,禁止执行可能产生线程阻塞的调用,例如:锁等待、远程调用等。
另外,事务要尽可能保持短事务,一个事务中不要有太多的操作,或者做太多的事情,长时间操作事务会影响或堵塞其他的请求,累积可造成数据库故障,同一事务中大量的数据操作会引起锁的范围和影响扩大,易造成数据库的其他操作阻塞而导致短暂的不可用。
因此,如果业务允许,要尽可能用短事务来代替长事务,降低事务执行时间,减少锁的时长,使用最终一致性来保证数据的一致性原则。
我们推荐下图中的这种结构。
一定不能使用如下图中的这种结构。
SQL 安全
所有的 SQL 必须使用参数化的 SQL,防止 SQL 注入,这是一条不能妥协的底线原则。
一行代码引起的“血案”
在做支付平台的设计评审的时候,我们一定要格外仔细,因为一不注意可能就会出现问题,甚至导致资金损失,笔者就经历一次增加一行打印日志的代码导致的“血案”。
在一次查问题的过程中,发现缺少一个日志,于是,增加了一行日志。
log.info(... + obj);
很不巧,上线以后应用就全面出现问题,交易出现失败,查看代码发现不时的有 NullPointerException,分析代码发现,出现 NullPointerException 的代码在 obj.toString() 方法里。
object.toString() 方法代码如下所示。
private Object fld1;
......public String toString() { return ... + this.fld1;
}
我们看见,在 obj.toString() 方法里面,直接使用了本地的变量 fld1,由于返回值是 String 类型,所以,Java 会试图将 fld1 转化成字符串,但是这个时候发生了 NullPointerException,那么,fld1就一定为 null,查明原因发现,这个对象是从缓存中反序列化而来的,反序列化的时候这个字段就为 null。
因此,我们看到线上的代码和环境是十分复杂的,在做设计评审的时候,一定要考虑到所有的情况,尽可能的将影响想得全面些,充分的降低代码变更带来的降低可用性的风险。
幂等和防重
幂等和防重虽然说起来挺复杂,但是实现起来很简单,这也就应了笔者的一句话:凡是能够有效解决问题的方法都是看起来很挫的方法”。
幂等是一个特性,一个操作执行多次,产生的结果是一样的,就成为幂等,用数学公式表达如下。
f(f(x)) = f(x)
对于某些业务具有的特点,操作本身就是幂等的,例如:删除一个资源、增加一个资源、获得一个资源等。
防重是实现幂等的一种方法,防重有多种方法。
-
使用数据库表的唯一键进行滤重,拒绝重复的请求,这通常用在增加记录上,只要记录有唯一的主键,这种方法失踪奏效。
-
使用状态流转的方向性来滤重,通常使用上面的行级锁来实现,这通常是在接受到回调消息的时候,要对记录的状态进行更新,可以使用行级锁来更新数据库的状态,然后根据更新的成功与否来判断继续处理的业务逻辑,例如,收到支付成功消息,会先把支付记录从 init 更新成 pay_finished,如果有重复的请求,第二个更新的请求会失败。
-
使用分布式存储对请求进行滤重,这个实现起来成本比较高。
实现分布式任务调度的多种方法
使用成熟的框架
可以使用成熟的开源分布式任务调用系统,例如 TBSchedule、ElasticJob 等等。
详细内容,请参考《可伸缩服务架构:框架与中间件》的第6章的内容。
代码自行实现
如果不喜欢使用成熟的框架,喜欢重复发明轮子,或者平台有要求,不准引入外部的开源项目,那么这个时候就是我们大显身手的时候了,我们可以自己开发一套分布式任务调度系统。
其实,分布式任务调度系统的核心就是任务的抢占,这和操作系统的任务调度类似,只不过应用的场景不同而已,操作系统处理各个应用进程提交的任务,而我们的分布式任务调度系统处理服务化系统中的后台定时任务。
假设,我们有4个后台定时的服务节点,以及4个任务存储在数据库的任务表中,如下图所示,所有的任务都处于空闲状态,拥有者为空,4台服务器都没有工作可做。
到了某个时间点,激活服务节点的定时任务,服务节点开始抢占任务,抢占任务需要更新数据库里面的记录状态字段和拥有者,一般会使用数据库的行级别锁,代码如下。
boolean result = executeSql("update ... set status = 'occupied' and owner = $node_no where id = $id and status = 'FREE' limit 1");if (result) {
Task t = executeSql("select ... where status = 'occupied' and owner = $node_no");
// process task t
executeSql("update ... set status = 'finished' and owner = null where id = $t.id and status = 'occupied');
}
假设服务节点1抢占了任务号1,服务节点2抢占了任务号2,服务节点3抢占了任务号3,服务节点4抢占了任务号4,如下图所示,这样各自开始处理自己的任务,处理后,将任务状态设置成 finished,其他服务节点就不会抢占这个任务了。
当然,这里描述的只是核心思想,具体实现的时候需要详细的设计,要考虑到任务如何调度、任务超时如何处理等等。
利用 Dubbo 服务化或者具有负载均衡的服务化平台来实现
假如说平台规定不能使用第三方开源组件,自己开发又比较耗时耗力,那么还有一种办法,这种办法虽然看起来不是最佳的,但是能够帮助你快速实现任务的分片。
我们可以借助 Dubbo 服务化或者具有负载均衡的服务来实现,我们在服务节点上开发两个服务,一个总控服务,用来接受分布式定时的触发事件,总控服务从数据库里面捞取任务,然后分发任务,分发任务利用 Dubbo 服务化或者具有负载均衡的服务化平台来实现,也就是调用服务节点的任务处理服务,通过服务化的负载均衡来实现。
例如,下图中分布式定时调用服务节点2的主控服务,主控服务从数据库里面捞取任务,并且分成4个分片,然后通过服务化调用任务处理接口,由于服务化具有负载均衡的功能,因此,4个分片会均衡的分布在服务节点1、服务节点2、服务节点3、服务节点4上。
当然,这种方法需要把后台的定时任务与前台的服务相互隔离,不能影响正常的线上服务是底线。